Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to

- ♿(frontend) improve accessibility:
- ♿(frontend) add skip to content button for keyboard accessibility #1624
- ⚡️(frontend) Enhance/html copy to download #1669

### Fixed

Expand All @@ -36,6 +37,9 @@ and this project adheres to
- ⚡️(sw) stop to cache external resources likes videos #1655
- 💥(frontend) upgrade to ui-kit v2 #1605
- ⚡️(frontend) improve perf on upload and table of contents #1662

### Fixed

Comment on lines +40 to +42
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
### Fixed

- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
Expand Down
86 changes: 85 additions & 1 deletion src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'path';

import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import JSZip from 'jszip';
import { PDFParse } from 'pdf-parse';

import {
Expand Down Expand Up @@ -31,7 +32,7 @@ test.describe('Doc Export', () => {

await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
Expand Down Expand Up @@ -187,6 +188,89 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});

test('it exports the doc to html zip', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-html-zip',
browserName,
1,
);

await verifyDocName(page, randomDoc);

// Add some content and at least one image so that the ZIP contains media files.
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello HTML ZIP');

await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();

const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();

const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));

const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();

// Wait for the image to be attached and have a valid src (aria-hidden prevents toBeVisible on Chromium)
await expect(image).toBeAttached({ timeout: 10000 });
await expect(image).toHaveAttribute('src', /.*\.svg/);

// Give some time for the image to be fully processed
await page.waitForTimeout(1000);

await page
.getByRole('button', {
name: 'Export the document',
})
.click();

await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'HTML' }).click();

await expect(page.getByTestId('doc-export-download-button')).toBeVisible();

const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.zip`);
});

void page.getByTestId('doc-export-download-button').click();

const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.zip`);

const zipBuffer = await cs.toBuffer(await download.createReadStream());
// Unzip and inspect contents
const zip = await JSZip.loadAsync(zipBuffer);

// Check that index.html exists
const indexHtml = zip.file('index.html');
expect(indexHtml).not.toBeNull();

// Read and verify HTML content
const htmlContent = await indexHtml!.async('string');
console.log('HTML content preview:', htmlContent.substring(0, 1000));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
console.log('HTML content preview:', htmlContent.substring(0, 1000));

expect(htmlContent).toContain('Hello HTML ZIP');

// Check for media files (they are at the root of the ZIP, not in a media/ folder)
// Media files are named like "1-test.svg" or "media-1.png" by deriveMediaFilename
const allFiles = Object.keys(zip.files);
console.log('All files in ZIP:', allFiles);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
console.log('All files in ZIP:', allFiles);

const mediaFiles = allFiles.filter(
(name) => name !== 'index.html' && !name.endsWith('/'),
);
console.log('Media files found:', mediaFiles);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
console.log('Media files found:', mediaFiles);

expect(mediaFiles.length).toBeGreaterThan(0);

// Verify the SVG image is included
const svgFile = mediaFiles.find((name) => name.endsWith('.svg'));
expect(svgFile).toBeDefined();
});

/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are being displayed correctly
Expand Down
20 changes: 11 additions & 9 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,10 @@ test.describe('Doc Header', () => {
expect(clipboardContent.trim()).toBe('# Hello World');
});

test('It checks the copy as HTML button', async ({ page, browserName }) => {
test('It no longer shows the copy as HTML button', async ({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can remove totally this test, we don't assert things that does not exist anymore.

page,
browserName,
}) => {
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
Expand All @@ -429,17 +432,16 @@ test.describe('Doc Header', () => {
const docFirstBlockContent = docFirstBlock.locator('h1');
await expect(docFirstBlockContent).toHaveText('Hello World');

// Copy content to clipboard
// Open document options menu
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();

// Test that clipboard is in HTML format
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
// We should no longer see "Copy as HTML" in the menu
await expect(
page.getByRole('menuitem', { name: 'Copy as Markdown' }),
).toBeVisible();
await expect(page.getByRole('menu').getByText('Copy as HTML')).toHaveCount(
0,
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
});

test('it checks the copy link button', async ({ page, browserName }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');

expect(Export.default).toBeUndefined();
}, 10000);
}, 60000);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, is 1mn really necessary, seems big ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test was a bit flaky, I saw it fail occasionally and one run even took ~9s, so I put timeout to 1min a bit aggressively 😅
We could probably reduce it to 15–20s now ? Wdyt

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes 15 or 20 max, if it takes more than that it means there is a problem somewhere.


it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const Export = await import('@/features/docs/doc-export/');

expect(Export.default).toHaveProperty('ModalExport');
});
}, 60000);
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
import JSZip from 'jszip';
import { cloneElement, isValidElement, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
Expand All @@ -26,9 +27,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',
Expand Down Expand Up @@ -142,13 +148,75 @@ 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);

Comment on lines +177 to +178
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see you use the CORS proxy, but I think it works only with images:

if not content_type.startswith("image/"):
return drf.response.Response(
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
)

Copy link
Collaborator Author

@Ovgodd Ovgodd Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yes, I see, the current CORS proxy only supports images.
So that means external audio / video (when they’re not uploaded to our backend) are rejected and can’t be added to the HTML ZIP? ( as you said sometimes mp3 and vidéos are not exported )
In that case, should we update the backend proxy to also allow audio and video so these files can be exported too?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are 2 types of "link" (internal / external), if the resources are internal like https://docs.numerique.gouv.fr/media/..., we have to download them and put them in the zip files because they will not be accessible otherwise; on the other hand, if the resources are external like https://youtube.com/kefseklsfes.mp4, I don't think we should download them but just let the src as it was, moreover we don't have the hand on the external resources they could be huge (1go) or even infected.

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';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const lang = i18next.language || 'fr';
const lang = i18next.language || fallbackLng;

From: https://github.com/suitenumerique/docs/blob/999c81499a087214c8de72575f202bbfc5e015a1/src/frontend/apps/impress/src/i18n/config.ts#L1-L2

const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;

const htmlContent = generateHtmlDocument(
documentTitle,
editorHtmlWithLocalMedia,
lang,
);

const zip = new JSZip();
zip.file('index.html', htmlContent);

mediaFiles.forEach(({ filename, blob }) => {
zip.file(filename, blob);
});

blobExport = await zip.generateAsync({ type: 'blob' });
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
return;
}

downloadFile(blobExport, `${filename}.${format}`);
const downloadExtension =
format === DocDownloadFormat.HTML ? 'zip' : format;

downloadFile(blobExport, `${filename}.${downloadExtension}`);

toast(
t('Your {{format}} was downloaded succesfully', {
Expand Down Expand Up @@ -225,18 +293,10 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
className="--docs--modal-export-content"
>
<Text $variation="secondary" $size="sm" as="p">
{t('Download your document in a .docx, .odt or .pdf format.')}
{t(
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
)}
</Text>
<Select
clearable={false}
fullWidth
label={t('Template')}
options={templateOptions}
value={templateSelected}
onChange={(options) =>
setTemplateSelected(options.target.value as string)
}
/>
<Select
clearable={false}
fullWidth
Expand All @@ -245,12 +305,24 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
{ 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)
}
/>
<Select
clearable={false}
fullWidth
label={t('Template')}
options={templateOptions}
value={templateSelected}
disabled={format === DocDownloadFormat.HTML}
onChange={(options) =>
setTemplateSelected(options.target.value as string)
}
/>

{isExporting && (
<Box
Expand Down
Loading
Loading