Skip to content

Commit f2ccb52

Browse files
committed
♿️(frontend) make html export accessible to screen reader users
adjusted structure and semantics to ensure proper sr interpretation Signed-off-by: Cyril <[email protected]>
1 parent 8901c6e commit f2ccb52

File tree

7 files changed

+516
-177
lines changed

7 files changed

+516
-177
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ and this project adheres to
1616

1717
- ✅(backend) reduce flakiness on backend test #1769
1818

19+
### Changed
20+
21+
- ♿(frontend) improve accessibility:
22+
- ♿(frontend) make html export accessible to screen reader users #1743
23+
1924
## [4.3.0] - 2026-01-05
2025

2126
### Added
@@ -60,7 +65,6 @@ and this project adheres to
6065
- 🐛(frontend) Select text + Go back one page crash the app #1733
6166
- 🐛(frontend) fix versioning conflict #1742
6267

63-
6468
## [4.1.0] - 2025-12-09
6569

6670
### Added

src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMediaFilename.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { deriveMediaFilename } from '../utils';
1+
import { deriveMediaFilename } from '../utils_html';
22

33
describe('deriveMediaFilename', () => {
44
test('uses last URL segment when src is a valid URL', () => {

src/frontend/apps/impress/src/features/docs/doc-export/assets/export-html-styles.txt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,75 @@ s {
184184
margin: 0;
185185
}
186186

187+
/* Remove bullet points from checkbox lists */
188+
ul.checklist,
189+
ul:has(li input[type='checkbox']) {
190+
list-style: none;
191+
padding-left: 0;
192+
margin-left: 0;
193+
}
194+
195+
ul.checklist li,
196+
ul:has(li input[type='checkbox']) li {
197+
list-style: none;
198+
display: flex;
199+
align-items: center;
200+
gap: 8px;
201+
}
202+
203+
ul.checklist li input[type='checkbox'],
204+
ul:has(li input[type='checkbox']) li input[type='checkbox'] {
205+
margin: 0;
206+
width: 16px;
207+
height: 16px;
208+
cursor: pointer;
209+
flex-shrink: 0;
210+
}
211+
212+
ul.checklist li p,
213+
ul:has(li input[type='checkbox']) li p {
214+
margin: 0;
215+
flex: 1;
216+
}
217+
218+
/* Native HTML Lists - remove default margins */
219+
ol,
220+
ul {
221+
margin: 0;
222+
padding-left: 24px;
223+
}
224+
225+
ol {
226+
list-style-type: decimal;
227+
}
228+
229+
ul {
230+
list-style-type: disc;
231+
}
232+
233+
/* Nested lists */
234+
ul ul {
235+
list-style-type: circle;
236+
}
237+
238+
/* Keep decimal numbering for nested ol (remove this if you want letters) */
239+
ol ol {
240+
list-style-type: decimal;
241+
}
242+
243+
li {
244+
margin: 0;
245+
padding: 0;
246+
line-height: 24px;
247+
}
248+
249+
li p {
250+
margin: 0;
251+
display: inline;
252+
}
253+
254+
255+
187256
/* Quotes */
188257
blockquote,
189258
.bn-block-content[data-content-type='quote'] blockquote {

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
2929
import { docxDocsSchemaMappings } from '../mappingDocx';
3030
import { odtDocsSchemaMappings } from '../mappingODT';
3131
import { pdfDocsSchemaMappings } from '../mappingPDF';
32+
import { downloadFile } from '../utils';
3233
import {
3334
addMediaFilesToZip,
34-
downloadFile,
3535
generateHtmlDocument,
36-
} from '../utils';
36+
improveHtmlAccessibility,
37+
} from '../utils_html';
3738

3839
enum DocDownloadFormat {
3940
HTML = 'html',
@@ -161,10 +162,12 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
161162

162163
const zip = new JSZip();
163164

165+
improveHtmlAccessibility(parsedDocument, documentTitle);
164166
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
165167

166168
const lang = i18next.language || fallbackLng;
167-
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
169+
const body = parsedDocument.body;
170+
const editorHtmlWithLocalMedia = body ? body.innerHTML : '';
168171

169172
const htmlContent = generateHtmlDocument(
170173
documentTitle,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
export * from './api';
77
export * from './utils';
8+
export * from './utils_html';
89

910
import * as ModalExport from './components/ModalExport';
1011

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

Lines changed: 0 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,8 @@ import {
55
} from '@blocknote/core';
66
import { Canvg } from 'canvg';
77
import { IParagraphOptions, ShadingType } from 'docx';
8-
import JSZip from 'jszip';
98
import React from 'react';
109

11-
import { exportResolveFileUrl } from './api';
12-
1310
export function downloadFile(blob: Blob, filename: string) {
1411
const url = window.URL.createObjectURL(blob);
1512
const a = document.createElement('a');
@@ -192,172 +189,3 @@ export function odtRegisterParagraphStyleForBlock(
192189

193190
return styleName;
194191
}
195-
196-
// Escape user-provided text before injecting it into the exported HTML document.
197-
export const escapeHtml = (value: string): string =>
198-
value
199-
.replace(/&/g, '&amp;')
200-
.replace(/</g, '&lt;')
201-
.replace(/>/g, '&gt;')
202-
.replace(/"/g, '&quot;')
203-
.replace(/'/g, '&#39;');
204-
205-
interface MediaFilenameParams {
206-
src: string;
207-
index: number;
208-
blob: Blob;
209-
}
210-
211-
/**
212-
* Derives a stable, readable filename for media exported in the HTML ZIP.
213-
*
214-
* Rules:
215-
* - Default base name is "media-{index+1}".
216-
* - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
217-
* - If the base name has no extension, we try to infer one from the blob MIME type.
218-
*/
219-
export const deriveMediaFilename = ({
220-
src,
221-
index,
222-
blob,
223-
}: MediaFilenameParams): string => {
224-
// Default base name
225-
let baseName = `media-${index + 1}`;
226-
227-
// Try to reuse the last path segment for non data URLs.
228-
if (!src.startsWith('data:')) {
229-
try {
230-
const url = new URL(src, window.location.origin);
231-
const lastSegment = url.pathname.split('/').pop();
232-
if (lastSegment) {
233-
baseName = `${index + 1}-${lastSegment}`;
234-
}
235-
} catch {
236-
// Ignore invalid URLs, keep default baseName.
237-
}
238-
}
239-
240-
let filename = baseName;
241-
242-
// Ensure the filename has an extension consistent with the blob MIME type.
243-
const mimeType = blob.type;
244-
if (mimeType && !baseName.includes('.')) {
245-
const slashIndex = mimeType.indexOf('/');
246-
const rawSubtype =
247-
slashIndex !== -1 && slashIndex < mimeType.length - 1
248-
? mimeType.slice(slashIndex + 1)
249-
: '';
250-
251-
let extension = '';
252-
const subtype = rawSubtype.toLowerCase();
253-
254-
if (subtype.includes('svg')) {
255-
extension = 'svg';
256-
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
257-
extension = 'jpg';
258-
} else if (subtype.includes('png')) {
259-
extension = 'png';
260-
} else if (subtype.includes('gif')) {
261-
extension = 'gif';
262-
} else if (subtype.includes('webp')) {
263-
extension = 'webp';
264-
} else if (subtype.includes('pdf')) {
265-
extension = 'pdf';
266-
} else if (subtype) {
267-
extension = subtype.split('+')[0];
268-
}
269-
270-
if (extension) {
271-
filename = `${baseName}.${extension}`;
272-
}
273-
}
274-
275-
return filename;
276-
};
277-
278-
/**
279-
* Generates a complete HTML document structure for export.
280-
*
281-
* @param documentTitle - The title of the document (will be escaped)
282-
* @param editorHtmlWithLocalMedia - The HTML content from the editor
283-
* @param lang - The language code for the document (e.g., 'fr', 'en')
284-
* @returns A complete HTML5 document string
285-
*/
286-
export const generateHtmlDocument = (
287-
documentTitle: string,
288-
editorHtmlWithLocalMedia: string,
289-
lang: string,
290-
): string => {
291-
return `<!DOCTYPE html>
292-
<html lang="${lang}">
293-
<head>
294-
<meta charset="utf-8" />
295-
<title>${escapeHtml(documentTitle)}</title>
296-
<link rel="stylesheet" href="styles.css">
297-
</head>
298-
<body>
299-
<main role="main">
300-
${editorHtmlWithLocalMedia}
301-
</main>
302-
</body>
303-
</html>`;
304-
};
305-
306-
export const addMediaFilesToZip = async (
307-
parsedDocument: Document,
308-
zip: JSZip,
309-
mediaUrl: string,
310-
) => {
311-
const mediaFiles: { filename: string; blob: Blob }[] = [];
312-
const mediaElements = Array.from(
313-
parsedDocument.querySelectorAll<
314-
HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLSourceElement
315-
>('img, video, audio, source'),
316-
);
317-
318-
await Promise.all(
319-
mediaElements.map(async (element, index) => {
320-
const src = element.getAttribute('src');
321-
322-
if (!src) {
323-
return;
324-
}
325-
326-
// data: URLs are already embedded and work offline; no need to create separate files.
327-
if (src.startsWith('data:')) {
328-
return;
329-
}
330-
331-
// Only download same-origin resources (internal media like /media/...).
332-
// External URLs keep their original src and are not included in the ZIP
333-
let url: URL | null = null;
334-
try {
335-
url = new URL(src, mediaUrl);
336-
} catch {
337-
url = null;
338-
}
339-
340-
if (!url || url.origin !== mediaUrl) {
341-
return;
342-
}
343-
344-
const fetched = await exportResolveFileUrl(url.href);
345-
346-
if (!(fetched instanceof Blob)) {
347-
return;
348-
}
349-
350-
const filename = deriveMediaFilename({
351-
src: url.href,
352-
index,
353-
blob: fetched,
354-
});
355-
element.setAttribute('src', filename);
356-
mediaFiles.push({ filename, blob: fetched });
357-
}),
358-
);
359-
360-
mediaFiles.forEach(({ filename, blob }) => {
361-
zip.file(filename, blob);
362-
});
363-
};

0 commit comments

Comments
 (0)