(frontend) print a doc with native browser

We can now print a doc with the native browser
print dialog.
This feature uses the browser's built-in print
capabilities to generate a print preview and
allows users to print directly from the application.
It has as well a powerfull print to PDF feature
that leverages the browser's PDF generation
capabilities for better compatibility and
quality.

Co-authored-by: AntoLC <anthony.le-courric@mail.numerique.gouv.fr>
Co-authored-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Anthony LC
2026-02-04 11:51:10 +01:00
parent 48df68195a
commit 5d8741a70a
8 changed files with 377 additions and 33 deletions

View File

@@ -6,10 +6,15 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
✨(frontend) Can print a doc #1832
### Changed ### Changed
- ♿️(frontend) Focus main container after navigation #1854 - ♿️(frontend) Focus main container after navigation #1854
### Fixed ### Fixed
🐛(frontend) fix broadcast store sync #1846 🐛(frontend) fix broadcast store sync #1846

View File

@@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { Download, Page, expect, test } from '@playwright/test'; import { Page, expect, test } from '@playwright/test';
import cs from 'convert-stream'; import cs from 'convert-stream';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { PDFParse } from 'pdf-parse'; import { PDFParse } from 'pdf-parse';
@@ -33,7 +33,9 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible(); await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect( await expect(
page.getByText(/Download your document in a \.docx, \.odt.*format\./i), page.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible(); await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect( await expect(
@@ -306,6 +308,50 @@ test.describe('Doc Export', () => {
expect(pdfString).toContain('/Lang (fr)'); expect(pdfString).toContain('/Lang (fr)');
}); });
test('it exports the doc to PDF with PRINT feature and checks regressions', async ({
page,
browserName,
}) => {
await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Print' }).click();
await page.getByRole('button', { name: 'Print' }).click();
await expect(page.locator('#print-only-content-styles')).toBeAttached();
await page.emulateMedia({ media: 'print' });
const pdfBuffer = await page.pdf({
printBackground: true,
preferCSSPageSize: true,
format: 'A4',
scale: 1,
});
// If we need to update the PDF regression fixture, uncomment the line below
// await savePDFToAssetFolder(
// pdfBuffer,
// 'doc-export-PDF-browser-regressions.pdf',
// );
// Assert the generated PDF matches the initial PDF regression fixture
await comparePDFWithAssetFolder(
pdfBuffer,
'doc-export-PDF-browser-regressions.pdf',
false,
);
await expect(page.locator('#print-only-content-styles')).not.toBeAttached();
});
test('it exports the doc to PDF and checks regressions', async ({ test('it exports the doc to PDF and checks regressions', async ({
page, page,
browserName, browserName,
@@ -325,10 +371,6 @@ test.describe('Doc Export', () => {
}) })
.click(); .click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => { const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`); return download.suggestedFilename().includes(`${randomDoc}.pdf`);
}); });
@@ -339,28 +381,29 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
// If we need to update the PDF regression fixture, uncomment the line below // If we need to update the PDF regression fixture, uncomment the line below
//await savePDFToAssetFolder(download); const pdfBuffer = await cs.toBuffer(await download.createReadStream());
//await savePDFToAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
// Assert the generated PDF matches "assets/doc-export-regressions.pdf" // Assert the generated PDF matches "assets/doc-export-regressions.pdf"
await comparePDFWithAssetFolder(download); await comparePDFWithAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
}); });
}); });
export const savePDFToAssetFolder = async (download: Download) => { export const savePDFToAssetFolder = async (
const pdfBuffer = await cs.toBuffer(await download.createReadStream()); pdfBuffer: Buffer,
const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`); filename: string,
) => {
const pdfPath = path.join(__dirname, 'assets', filename);
fs.writeFileSync(pdfPath, pdfBuffer); fs.writeFileSync(pdfPath, pdfBuffer);
}; };
export const comparePDFWithAssetFolder = async (download: Download) => { export const comparePDFWithAssetFolder = async (
const pdfBuffer = await cs.toBuffer(await download.createReadStream()); pdfBuffer: Buffer,
filename: string,
compareTextContent = true,
) => {
// Load reference PDF for comparison // Load reference PDF for comparison
const referencePdfPath = path.join( const referencePdfPath = path.join(__dirname, 'assets', filename);
__dirname,
'assets',
'doc-export-regressions.pdf',
);
const referencePdfBuffer = fs.readFileSync(referencePdfPath); const referencePdfBuffer = fs.readFileSync(referencePdfPath);
@@ -387,8 +430,16 @@ export const comparePDFWithAssetFolder = async (download: Download) => {
// Compare page count // Compare page count
expect(generatedInfo.total).toBe(referenceInfo.total); expect(generatedInfo.total).toBe(referenceInfo.total);
// Compare text content /*
expect(generatedText.text).toBe(referenceText.text); Compare text content
We make this optional because text extraction from PDFs can vary
slightly between environments and PDF versions, leading to false negatives.
Particularly with emojis which can be represented differently when
exporting or parsing the PDF.
*/
if (compareTextContent) {
expect(generatedText.text).toBe(referenceText.text);
}
// Compare screenshots page by page // Compare screenshots page by page
for (let i = 0; i < generatedScreenshot.pages.length; i++) { for (let i = 0; i < generatedScreenshot.pages.length; i++) {

View File

@@ -34,12 +34,14 @@ import {
generateHtmlDocument, generateHtmlDocument,
improveHtmlAccessibility, improveHtmlAccessibility,
} from '../utils_html'; } from '../utils_html';
import { printDocumentWithStyles } from '../utils_print';
enum DocDownloadFormat { enum DocDownloadFormat {
HTML = 'html', HTML = 'html',
PDF = 'pdf', PDF = 'pdf',
DOCX = 'docx', DOCX = 'docx',
ODT = 'odt', ODT = 'odt',
PRINT = 'print',
} }
interface ModalExportProps { interface ModalExportProps {
@@ -66,6 +68,14 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
setIsExporting(true); setIsExporting(true);
// Handle print separately as it doesn't download a file
if (format === DocDownloadFormat.PRINT) {
printDocumentWithStyles();
setIsExporting(false);
onClose();
return;
}
const filename = (doc.title || untitledDocument) const filename = (doc.title || untitledDocument)
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
@@ -199,13 +209,15 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
</Button> </Button>
<Button <Button
data-testid="doc-export-download-button" data-testid="doc-export-download-button"
aria-label={t('Download')} aria-label={
format === DocDownloadFormat.PRINT ? t('Print') : t('Download')
}
variant="primary" variant="primary"
fullWidth fullWidth
onClick={() => void onSubmit()} onClick={() => void onSubmit()}
disabled={isExporting} disabled={isExporting}
> >
{t('Download')} {format === DocDownloadFormat.PRINT ? t('Print') : t('Download')}
</Button> </Button>
</> </>
} }
@@ -225,7 +237,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
$align="flex-start" $align="flex-start"
data-testid="modal-export-title" data-testid="modal-export-title"
> >
{t('Download')} {t('Export')}
</Text> </Text>
<ButtonCloseModal <ButtonCloseModal
aria-label={t('Close the download modal')} aria-label={t('Close the download modal')}
@@ -243,7 +255,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
> >
<Text $variation="secondary" $size="sm" as="p"> <Text $variation="secondary" $size="sm" as="p">
{t( {t(
'Download your document in a .docx, .odt, .pdf or .html(zip) format.', 'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
)} )}
</Text> </Text>
<Select <Select
@@ -251,10 +263,11 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
fullWidth fullWidth
label={t('Format')} label={t('Format')}
options={[ options={[
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('Docx'), value: DocDownloadFormat.DOCX }, { label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT }, { label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('HTML'), value: DocDownloadFormat.HTML }, { label: t('HTML'), value: DocDownloadFormat.HTML },
{ label: t('Print'), value: DocDownloadFormat.PRINT },
]} ]}
value={format} value={format}
onChange={(options) => onChange={(options) =>

View File

@@ -11,6 +11,12 @@ export const escapeHtml = (value: string): string =>
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
const moveChildNodes = (from: Element, to: Element) => {
while (from.firstChild) {
to.appendChild(from.firstChild);
}
};
/** /**
* Derives a stable, readable filename for media exported in the HTML ZIP. * Derives a stable, readable filename for media exported in the HTML ZIP.
* *
@@ -138,7 +144,7 @@ export const improveHtmlAccessibility = (
const rawLevel = Number(block.getAttribute('data-level')) || 1; const rawLevel = Number(block.getAttribute('data-level')) || 1;
const level = Math.min(Math.max(rawLevel, 1), 6); const level = Math.min(Math.max(rawLevel, 1), 6);
const heading = parsedDocument.createElement(`h${level}`); const heading = parsedDocument.createElement(`h${level}`);
heading.innerHTML = block.innerHTML; moveChildNodes(block, heading);
block.replaceWith(heading); block.replaceWith(heading);
}); });
@@ -252,7 +258,7 @@ export const improveHtmlAccessibility = (
// Create list item and add content // Create list item and add content
const li = parsedDocument.createElement('li'); const li = parsedDocument.createElement('li');
li.innerHTML = listItem.innerHTML; moveChildNodes(listItem, li);
targetList.appendChild(li); targetList.appendChild(li);
// Remove original block-outer // Remove original block-outer
@@ -265,7 +271,7 @@ export const improveHtmlAccessibility = (
); );
quoteBlocks.forEach((block) => { quoteBlocks.forEach((block) => {
const quote = parsedDocument.createElement('blockquote'); const quote = parsedDocument.createElement('blockquote');
quote.innerHTML = block.innerHTML; moveChildNodes(block, quote);
block.replaceWith(quote); block.replaceWith(quote);
}); });
@@ -276,7 +282,7 @@ export const improveHtmlAccessibility = (
calloutBlocks.forEach((block) => { calloutBlocks.forEach((block) => {
const aside = parsedDocument.createElement('aside'); const aside = parsedDocument.createElement('aside');
aside.setAttribute('role', 'note'); aside.setAttribute('role', 'note');
aside.innerHTML = block.innerHTML; moveChildNodes(block, aside);
block.replaceWith(aside); block.replaceWith(aside);
}); });
@@ -303,7 +309,7 @@ export const improveHtmlAccessibility = (
} }
const li = parsedDocument.createElement('li'); const li = parsedDocument.createElement('li');
li.innerHTML = item.innerHTML; moveChildNodes(item, li);
// Ensure checkbox has an accessible state; fall back to aria-checked if missing. // Ensure checkbox has an accessible state; fall back to aria-checked if missing.
const checkbox = li.querySelector<HTMLInputElement>( const checkbox = li.querySelector<HTMLInputElement>(
@@ -340,7 +346,7 @@ export const improveHtmlAccessibility = (
}); });
// Move content inside <code>. // Move content inside <code>.
code.innerHTML = block.innerHTML; moveChildNodes(block, code);
pre.appendChild(code); pre.appendChild(code);
block.replaceWith(pre); block.replaceWith(pre);
}); });

View File

@@ -0,0 +1,266 @@
import { isSafeUrl } from '@/utils/url';
const PRINT_ONLY_CONTENT_STYLES_ID = 'print-only-content-styles';
const PRINT_APPLY_DELAY_MS = 200;
const PRINT_CLEANUP_DELAY_MS = 1000;
const PRINT_ONLY_CONTENT_CSS = `
@media print {
/* Reset body and html for proper pagination */
html, body {
height: auto !important;
overflow: visible !important;
background: var(--c--theme--colors--greyscale-000) !important;
margin: 0 !important;
padding: 0 !important;
}
/* Hide non-essential elements for printing */
.--docs--header,
.--docs--resizable-left-panel,
.--docs--doc-editor-header,
.--docs--doc-header,
.--docs--doc-toolbox,
.--docs--table-content,
.--docs--doc-footer,
.--docs--footer,
footer,
[role="contentinfo"],
div[data-is-empty-and-focused="true"],
div[data-floating-ui-focusable],
.collaboration-cursor-custom__base
{
display: none !important;
}
/* Hide selection highlights */
.ProseMirror-yjs-selection {
background-color: transparent !important;
}
/* Reset all layout containers for print flow */
.--docs--main-layout,
.--docs--main-layout > *,
main[role="main"],
#mainContent {
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
background: var(--c--theme--colors--greyscale-000) !important;
margin: 0 !important;
padding: 0 !important;
}
/* Prevent any ancestor from clipping the end of the document */
.--docs--main-layout,
.--docs--main-layout * {
overflow: visible !important;
max-height: none !important;
}
/* Allow editor containers to flow across pages */
.--docs--editor-container,
.--docs--doc-editor,
.--docs--doc-editor-content {
max-width: 100% !important;
width: 100% !important;
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
padding: 0 !important;
margin: 0 !important;
}
/* Reset all Box components that might have height constraints */
.--docs--doc-editor > div,
.--docs--doc-editor-content > div {
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
}
/* Ensure BlockNote content flows properly */
.bn-editor,
.bn-container,
.--docs--main-editor,
.bn-block-outer {
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
width: 100% !important;
max-width: 100% !important;
}
/* Hide media/embed placeholders and render their URLs */
[data-content-type="file"] .bn-file-block-content-wrapper,
[data-content-type="pdf"] .bn-file-block-content-wrapper,
[data-content-type="audio"] .bn-file-block-content-wrapper,
[data-content-type="video"] .bn-file-block-content-wrapper {
display: none !important;
}
div[data-page-break] {
opacity: 0;
}
/* Allow large blocks/media to split across pages */
.bn-block-content {
page-break-inside: avoid;
break-inside: avoid;
}
.--docs--main-editor {
width: 100% !important;
padding: 0.5cm !important;
}
/* Force print all colors and backgrounds */
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
/* Add minimal print margins */
@page {
margin: 0cm;
margin-bottom: 0.7cm;
margin-top: 0.7cm;
page-break-after: always;
}
.print-url-label {
text-decoration: none !important;
}
}
`;
/**
* Removes the print-only styles from the document head if they exist.
*/
function removePrintOnlyStyles() {
const stylesElement = document.getElementById(PRINT_ONLY_CONTENT_STYLES_ID);
if (stylesElement) {
stylesElement.remove();
}
}
/**
* Creates a style element containing CSS rules that are applied only during printing.
*/
function createPrintOnlyStyleElement() {
const printStyles = document.createElement('style');
printStyles.id = PRINT_ONLY_CONTENT_STYLES_ID;
printStyles.textContent = PRINT_ONLY_CONTENT_CSS;
return printStyles;
}
/**
* Removes any existing print-only styles and appends new ones to the document head.
*/
function appendPrintOnlyStyles() {
removePrintOnlyStyles();
document.head.appendChild(createPrintOnlyStyleElement());
return removePrintOnlyStyles;
}
/**
* Wraps media elements with links to their source URLs for printing.
*/
function wrapMediaWithLink() {
const createdShadowWrapper: HTMLElement[] = [];
const prependLink = (
el: Element,
url: string | null,
name: string | null,
type: 'file' | 'audio' | 'video' | 'pdf',
) => {
if (!url || !isSafeUrl(url)) {
return;
}
const block = document.createElement('div');
block.className = 'print-url-block-media';
const link = document.createElement('a');
link.className = 'print-url-link';
const label = document.createElement('span');
label.className = 'print-url-label';
if (type === 'audio') {
label.textContent = '🔊: ';
} else if (type === 'video') {
label.textContent = '📹: ';
} else if (type === 'pdf') {
label.textContent = '📑: ';
} else {
label.textContent = '🔗: ';
}
link.href = url;
link.textContent = name || url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.setAttribute('data-print-link', 'true');
block.appendChild(label);
block.appendChild(link);
const shadowWrapper = document.createElement('div');
el.prepend(shadowWrapper);
// Use a shadow root to avoid propagatic the changes to the collaboration provider
const shadowRoot = shadowWrapper.attachShadow({ mode: 'open' });
shadowRoot.appendChild(block);
createdShadowWrapper.push(shadowWrapper);
};
document
.querySelectorAll(
'[data-content-type="pdf"], [data-content-type="file"], [data-content-type="audio"], [data-content-type="video"]',
)
.forEach((el) => {
const url = el?.getAttribute('data-url');
const name = el?.getAttribute('data-name');
const type = el?.getAttribute('data-content-type') as
| 'file'
| 'audio'
| 'video'
| 'pdf';
if (type) {
prependLink(el, url, name, type);
}
});
return () => {
// remove the shadow roots that were created
createdShadowWrapper.forEach((link) => {
link.remove();
});
};
}
export function printDocumentWithStyles() {
if (typeof window === 'undefined') {
return;
}
const cleanupPrintStyles = appendPrintOnlyStyles();
// Small delay to ensure styles are applied
setTimeout(() => {
const cleanupLinks = wrapMediaWithLink();
const cleanup = () => {
cleanupLinks();
cleanupPrintStyles();
};
window.addEventListener('afterprint', cleanup, { once: true });
requestAnimationFrame(() => window.print());
// Also clean up after a delay as fallback
setTimeout(cleanup, PRINT_CLEANUP_DELAY_MS);
}, PRINT_APPLY_DELAY_MS);
}

View File

@@ -46,7 +46,9 @@ describe('DocToolBox - Licence', () => {
// Wait for the export modal to be visible, then assert on its content text. // Wait for the export modal to be visible, then assert on its content text.
await screen.findByTestId('modal-export-title'); await screen.findByTestId('modal-export-title');
expect( expect(
screen.getByText(/Download your document in a .docx, .odt.*format\./i), screen.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeInTheDocument(); ).toBeInTheDocument();
}, 10000); }, 10000);

View File

@@ -71,6 +71,7 @@ export const ResizableLeftPanel = ({
<PanelGroup direction="horizontal"> <PanelGroup direction="horizontal">
<Panel <Panel
ref={ref} ref={ref}
className="--docs--resizable-left-panel"
order={0} order={0}
defaultSize={ defaultSize={
isDesktop isDesktop