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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ and this project adheres to
- 🌐(backend) internationalize demo #1644
- ♿(frontend) improve accessibility:
- ♿️Improve keyboard accessibility for the document tree #1681
- ♿(frontend) make html export accessible to screen reader users #1743
Copy link
Collaborator

Choose a reason for hiding this comment

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

When rebase, it will have to go under "Unreleased".


### Fixed

- 🐛(frontend) paste content with comments from another document #1732
- 🐛(frontend) Select text + Go back one page crash the app #1733


## [4.1.0] - 2025-12-09

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
export * from './api';
export * from './utils';
export * from './utils_html';

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

Expand Down
172 changes: 0 additions & 172 deletions src/frontend/apps/impress/src/features/docs/doc-export/utils.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we add all the utility function about the html feature in a separate file like utils-html.ts ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes of course !

Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -182,172 +179,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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');

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 `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(documentTitle)}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main role="main">
${editorHtmlWithLocalMedia}
</main>
</body>
</html>`;
};

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);
});
};
Loading
Loading