diff --git a/CHANGELOG.md b/CHANGELOG.md
index c83596c2..7d1a8872 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,11 @@ and this project adheres to
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
+### Changed
+
+- ♿(frontend) improve accessibility:
+ - ♿(frontend) make html export accessible to screen reader users #1743
+
## [4.3.0] - 2026-01-05
### Added
@@ -65,7 +70,6 @@ and this project adheres to
- 🐛(frontend) Select text + Go back one page crash the app #1733
- 🐛(frontend) fix versioning conflict #1742
-
## [4.1.0] - 2025-12-09
### Added
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
index 1ac59492..68c59b6c 100644
--- 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
@@ -1,4 +1,4 @@
-import { deriveMediaFilename } from '../utils';
+import { deriveMediaFilename } from '../utils_html';
describe('deriveMediaFilename', () => {
test('uses last URL segment when src is a valid URL', () => {
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/assets/export-html-styles.txt b/src/frontend/apps/impress/src/features/docs/doc-export/assets/export-html-styles.txt
index 2e5a785d..18d55a60 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/assets/export-html-styles.txt
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/assets/export-html-styles.txt
@@ -184,6 +184,75 @@ s {
margin: 0;
}
+/* Remove bullet points from checkbox lists */
+ul.checklist,
+ul:has(li input[type='checkbox']) {
+ list-style: none;
+ padding-left: 0;
+ margin-left: 0;
+}
+
+ul.checklist li,
+ul:has(li input[type='checkbox']) li {
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+ul.checklist li input[type='checkbox'],
+ul:has(li input[type='checkbox']) li input[type='checkbox'] {
+ margin: 0;
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+ul.checklist li p,
+ul:has(li input[type='checkbox']) li p {
+ margin: 0;
+ flex: 1;
+}
+
+/* Native HTML Lists - remove default margins */
+ol,
+ul {
+ margin: 0;
+ padding-left: 24px;
+}
+
+ol {
+ list-style-type: decimal;
+}
+
+ul {
+ list-style-type: disc;
+}
+
+/* Nested lists */
+ul ul {
+ list-style-type: circle;
+}
+
+/* Keep decimal numbering for nested ol (remove this if you want letters) */
+ol ol {
+ list-style-type: decimal;
+}
+
+li {
+ margin: 0;
+ padding: 0;
+ line-height: 24px;
+}
+
+li p {
+ margin: 0;
+ display: inline;
+}
+
+
+
/* Quotes */
blockquote,
.bn-block-content[data-content-type='quote'] blockquote {
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 a70f3c91..2f41f7ef 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
@@ -29,11 +29,12 @@ import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
+import { downloadFile } from '../utils';
import {
addMediaFilesToZip,
- downloadFile,
generateHtmlDocument,
-} from '../utils';
+ improveHtmlAccessibility,
+} from '../utils_html';
enum DocDownloadFormat {
HTML = 'html',
@@ -161,10 +162,12 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const zip = new JSZip();
+ improveHtmlAccessibility(parsedDocument, documentTitle);
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
const lang = i18next.language || fallbackLng;
- const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
+ const body = parsedDocument.body;
+ const editorHtmlWithLocalMedia = body ? body.innerHTML : '';
const htmlContent = generateHtmlDocument(
documentTitle,
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts
index cb1ab543..bd56ea14 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts
@@ -5,6 +5,7 @@
*/
export * from './api';
export * from './utils';
+export * from './utils_html';
import * as ModalExport from './components/ModalExport';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts
index cf7cc404..72992ea2 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts
@@ -5,11 +5,8 @@ import {
} from '@blocknote/core';
import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
-import JSZip from 'jszip';
import React from 'react';
-import { exportResolveFileUrl } from './api';
-
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -192,172 +189,3 @@ export function odtRegisterParagraphStyleForBlock(
return styleName;
}
-
-// Escape user-provided text before injecting it into the exported HTML document.
-export const escapeHtml = (value: string): string =>
- value
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-
-interface MediaFilenameParams {
- src: string;
- index: number;
- blob: Blob;
-}
-
-/**
- * Derives a stable, readable filename for media exported in the HTML ZIP.
- *
- * Rules:
- * - Default base name is "media-{index+1}".
- * - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
- * - If the base name has no extension, we try to infer one from the blob MIME type.
- */
-export const deriveMediaFilename = ({
- src,
- index,
- blob,
-}: MediaFilenameParams): string => {
- // Default base name
- let baseName = `media-${index + 1}`;
-
- // Try to reuse the last path segment for non data URLs.
- if (!src.startsWith('data:')) {
- try {
- const url = new URL(src, window.location.origin);
- const lastSegment = url.pathname.split('/').pop();
- if (lastSegment) {
- baseName = `${index + 1}-${lastSegment}`;
- }
- } catch {
- // Ignore invalid URLs, keep default baseName.
- }
- }
-
- let filename = baseName;
-
- // Ensure the filename has an extension consistent with the blob MIME type.
- const mimeType = blob.type;
- if (mimeType && !baseName.includes('.')) {
- const slashIndex = mimeType.indexOf('/');
- const rawSubtype =
- slashIndex !== -1 && slashIndex < mimeType.length - 1
- ? mimeType.slice(slashIndex + 1)
- : '';
-
- let extension = '';
- const subtype = rawSubtype.toLowerCase();
-
- if (subtype.includes('svg')) {
- extension = 'svg';
- } else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
- extension = 'jpg';
- } else if (subtype.includes('png')) {
- extension = 'png';
- } else if (subtype.includes('gif')) {
- extension = 'gif';
- } else if (subtype.includes('webp')) {
- extension = 'webp';
- } else if (subtype.includes('pdf')) {
- extension = 'pdf';
- } else if (subtype) {
- extension = subtype.split('+')[0];
- }
-
- if (extension) {
- filename = `${baseName}.${extension}`;
- }
- }
-
- return filename;
-};
-
-/**
- * Generates a complete HTML document structure for export.
- *
- * @param documentTitle - The title of the document (will be escaped)
- * @param editorHtmlWithLocalMedia - The HTML content from the editor
- * @param lang - The language code for the document (e.g., 'fr', 'en')
- * @returns A complete HTML5 document string
- */
-export const generateHtmlDocument = (
- documentTitle: string,
- editorHtmlWithLocalMedia: string,
- lang: string,
-): string => {
- return `
-
-
-
- ${escapeHtml(documentTitle)}
-
-
-
-
-${editorHtmlWithLocalMedia}
-
-
-`;
-};
-
-export const addMediaFilesToZip = async (
- parsedDocument: Document,
- zip: JSZip,
- mediaUrl: string,
-) => {
- 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;
- }
-
- // data: URLs are already embedded and work offline; no need to create separate files.
- if (src.startsWith('data:')) {
- return;
- }
-
- // Only download same-origin resources (internal media like /media/...).
- // External URLs keep their original src and are not included in the ZIP
- let url: URL | null = null;
- try {
- url = new URL(src, mediaUrl);
- } catch {
- url = null;
- }
-
- if (!url || url.origin !== mediaUrl) {
- return;
- }
-
- const fetched = await exportResolveFileUrl(url.href);
-
- if (!(fetched instanceof Blob)) {
- return;
- }
-
- const filename = deriveMediaFilename({
- src: url.href,
- index,
- blob: fetched,
- });
- element.setAttribute('src', filename);
- mediaFiles.push({ filename, blob: fetched });
- }),
- );
-
- mediaFiles.forEach(({ filename, blob }) => {
- zip.file(filename, blob);
- });
-};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/utils_html.ts b/src/frontend/apps/impress/src/features/docs/doc-export/utils_html.ts
new file mode 100644
index 00000000..b22769be
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/utils_html.ts
@@ -0,0 +1,434 @@
+import JSZip from 'jszip';
+
+import { exportResolveFileUrl } from './api';
+
+// Escape user-provided text before injecting it into the exported HTML document.
+export const escapeHtml = (value: string): string =>
+ value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+/**
+ * Derives a stable, readable filename for media exported in the HTML ZIP.
+ *
+ * Rules:
+ * - Default base name is "media-{index+1}".
+ * - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
+ * - If the base name has no extension, we try to infer one from the blob MIME type.
+ */
+
+interface MediaFilenameParams {
+ src: string;
+ index: number;
+ blob: Blob;
+}
+
+export const deriveMediaFilename = ({
+ src,
+ index,
+ blob,
+}: MediaFilenameParams): string => {
+ // Default base name
+ let baseName = `media-${index + 1}`;
+
+ // Try to reuse the last path segment for non data URLs.
+ if (!src.startsWith('data:')) {
+ try {
+ const url = new URL(src, window.location.origin);
+ const lastSegment = url.pathname.split('/').pop();
+ if (lastSegment) {
+ baseName = `${index + 1}-${lastSegment}`;
+ }
+ } catch {
+ // Ignore invalid URLs, keep default baseName.
+ }
+ }
+
+ let filename = baseName;
+
+ // Ensure the filename has an extension consistent with the blob MIME type.
+ const mimeType = blob.type;
+ if (mimeType && !baseName.includes('.')) {
+ const slashIndex = mimeType.indexOf('/');
+ const rawSubtype =
+ slashIndex !== -1 && slashIndex < mimeType.length - 1
+ ? mimeType.slice(slashIndex + 1)
+ : '';
+
+ let extension = '';
+ const subtype = rawSubtype.toLowerCase();
+
+ if (subtype.includes('svg')) {
+ extension = 'svg';
+ } else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
+ extension = 'jpg';
+ } else if (subtype.includes('png')) {
+ extension = 'png';
+ } else if (subtype.includes('gif')) {
+ extension = 'gif';
+ } else if (subtype.includes('webp')) {
+ extension = 'webp';
+ } else if (subtype.includes('pdf')) {
+ extension = 'pdf';
+ } else if (subtype) {
+ extension = subtype.split('+')[0];
+ }
+
+ if (extension) {
+ filename = `${baseName}.${extension}`;
+ }
+ }
+
+ return filename;
+};
+
+/**
+ * Generates a complete HTML document structure for export.
+ *
+ * @param documentTitle - The title of the document (will be escaped)
+ * @param editorHtmlWithLocalMedia - The HTML content from the editor
+ * @param lang - The language code for the document (e.g., 'fr', 'en')
+ * @returns A complete HTML5 document string
+ */
+export const generateHtmlDocument = (
+ documentTitle: string,
+ editorHtmlWithLocalMedia: string,
+ lang: string,
+): string => {
+ return `
+
+
+
+ ${escapeHtml(documentTitle)}
+
+
+
+
+ ${editorHtmlWithLocalMedia}
+
+
+ `;
+};
+
+/**
+ * Enrich the HTML produced by the editor with semantic tags and basic a11y defaults.
+ *
+ * Notes:
+ * - We work directly on the parsed Document so modifications are reflected before we zip files.
+ * - We keep the editor inner structure but upgrade the key block types to native elements.
+ */
+export const improveHtmlAccessibility = (
+ parsedDocument: Document,
+ documentTitle: string,
+) => {
+ const body = parsedDocument.body;
+ if (!body) {
+ return;
+ }
+
+ // 1) Headings: convert heading blocks to h1-h6 based on data-level
+ const headingBlocks = Array.from(
+ body.querySelectorAll("[data-content-type='heading']"),
+ );
+
+ headingBlocks.forEach((block) => {
+ const rawLevel = Number(block.getAttribute('data-level')) || 1;
+ const level = Math.min(Math.max(rawLevel, 1), 6);
+ const heading = parsedDocument.createElement(`h${level}`);
+ heading.innerHTML = block.innerHTML;
+ block.replaceWith(heading);
+ });
+
+ // 2) Lists: convert to semantic OL/UL/LI elements for accessibility
+ const listItemSelector =
+ "[data-content-type='bulletListItem'], [data-content-type='numberedListItem']";
+
+ // Helper function to get nesting level by counting block-group ancestors
+ const getNestingLevel = (blockOuter: HTMLElement): number => {
+ let level = 0;
+ let parent = blockOuter.parentElement;
+ while (parent) {
+ if (parent.classList.contains('bn-block-group')) {
+ level++;
+ }
+ parent = parent.parentElement;
+ }
+ return level;
+ };
+
+ // Find all block-outer elements in document order
+ const allBlockOuters = Array.from(
+ body.querySelectorAll('.bn-block-outer'),
+ );
+
+ // Collect list items with their info before modifying DOM
+ interface ListItemInfo {
+ blockOuter: HTMLElement;
+ listItem: HTMLElement;
+ contentType: string;
+ level: number;
+ }
+
+ const listItemsInfo: ListItemInfo[] = [];
+ allBlockOuters.forEach((blockOuter) => {
+ const listItem = blockOuter.querySelector(listItemSelector);
+ if (listItem) {
+ const contentType = listItem.getAttribute('data-content-type');
+ if (contentType) {
+ const level = getNestingLevel(blockOuter);
+ listItemsInfo.push({
+ blockOuter,
+ listItem,
+ contentType,
+ level,
+ });
+ }
+ }
+ });
+
+ // Stack to track lists at each nesting level
+ const listStack: Array<{ list: HTMLElement; type: string; level: number }> =
+ [];
+
+ listItemsInfo.forEach((info, idx) => {
+ const { blockOuter, listItem, contentType, level } = info;
+ const isBullet = contentType === 'bulletListItem';
+ const listTag = isBullet ? 'ul' : 'ol';
+
+ // Check if previous item continues the same list (same type and level)
+ const previousInfo = idx > 0 ? listItemsInfo[idx - 1] : null;
+ const continuesPreviousList =
+ previousInfo &&
+ previousInfo.contentType === contentType &&
+ previousInfo.level === level;
+
+ // Find or create the appropriate list
+ let targetList: HTMLElement | null = null;
+
+ if (continuesPreviousList) {
+ // Continue with the list at this level from stack
+ const listAtLevel = listStack.find((item) => item.level === level);
+ targetList = listAtLevel?.list || null;
+ }
+
+ // If no list found, create a new one
+ if (!targetList) {
+ targetList = parsedDocument.createElement(listTag);
+
+ // Remove lists from stack that are at same or deeper level
+ while (
+ listStack.length > 0 &&
+ listStack[listStack.length - 1].level >= level
+ ) {
+ listStack.pop();
+ }
+
+ // If we have a parent list, nest this list inside its last li
+ if (
+ listStack.length > 0 &&
+ listStack[listStack.length - 1].level < level
+ ) {
+ const parentList = listStack[listStack.length - 1].list;
+ const lastLi = parentList.querySelector('li:last-child');
+ if (lastLi) {
+ lastLi.appendChild(targetList);
+ } else {
+ // No li yet, create one and add the nested list
+ const li = parsedDocument.createElement('li');
+ parentList.appendChild(li);
+ li.appendChild(targetList);
+ }
+ } else {
+ // Top-level list
+ blockOuter.parentElement?.insertBefore(targetList, blockOuter);
+ }
+
+ // Add to stack
+ listStack.push({ list: targetList, type: contentType, level });
+ }
+
+ // Create list item and add content
+ const li = parsedDocument.createElement('li');
+ li.innerHTML = listItem.innerHTML;
+ targetList.appendChild(li);
+
+ // Remove original block-outer
+ blockOuter.remove();
+ });
+
+ // 3) Quotes ->
+ const quoteBlocks = Array.from(
+ body.querySelectorAll("[data-content-type='quote']"),
+ );
+ quoteBlocks.forEach((block) => {
+ const quote = parsedDocument.createElement('blockquote');
+ quote.innerHTML = block.innerHTML;
+ block.replaceWith(quote);
+ });
+
+ // 4) Callouts ->