(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]
### Added
✨(frontend) Can print a doc #1832
### Changed
- ♿️(frontend) Focus main container after navigation #1854
### Fixed
🐛(frontend) fix broadcast store sync #1846

View File

@@ -1,7 +1,7 @@
import fs from 'fs';
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 JSZip from 'jszip';
import { PDFParse } from 'pdf-parse';
@@ -33,7 +33,9 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
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();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
@@ -306,6 +308,50 @@ test.describe('Doc Export', () => {
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 ({
page,
browserName,
@@ -325,10 +371,6 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
@@ -339,28 +381,29 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
// 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"
await comparePDFWithAssetFolder(download);
await comparePDFWithAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
});
});
export const savePDFToAssetFolder = async (download: Download) => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`);
export const savePDFToAssetFolder = async (
pdfBuffer: Buffer,
filename: string,
) => {
const pdfPath = path.join(__dirname, 'assets', filename);
fs.writeFileSync(pdfPath, pdfBuffer);
};
export const comparePDFWithAssetFolder = async (download: Download) => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
export const comparePDFWithAssetFolder = async (
pdfBuffer: Buffer,
filename: string,
compareTextContent = true,
) => {
// Load reference PDF for comparison
const referencePdfPath = path.join(
__dirname,
'assets',
'doc-export-regressions.pdf',
);
const referencePdfPath = path.join(__dirname, 'assets', filename);
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
@@ -387,8 +430,16 @@ export const comparePDFWithAssetFolder = async (download: Download) => {
// Compare page count
expect(generatedInfo.total).toBe(referenceInfo.total);
// Compare text content
/*
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
for (let i = 0; i < generatedScreenshot.pages.length; i++) {

View File

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

View File

@@ -11,6 +11,12 @@ export const escapeHtml = (value: string): string =>
.replace(/"/g, '&quot;')
.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.
*
@@ -138,7 +144,7 @@ export const improveHtmlAccessibility = (
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;
moveChildNodes(block, heading);
block.replaceWith(heading);
});
@@ -252,7 +258,7 @@ export const improveHtmlAccessibility = (
// Create list item and add content
const li = parsedDocument.createElement('li');
li.innerHTML = listItem.innerHTML;
moveChildNodes(listItem, li);
targetList.appendChild(li);
// Remove original block-outer
@@ -265,7 +271,7 @@ export const improveHtmlAccessibility = (
);
quoteBlocks.forEach((block) => {
const quote = parsedDocument.createElement('blockquote');
quote.innerHTML = block.innerHTML;
moveChildNodes(block, quote);
block.replaceWith(quote);
});
@@ -276,7 +282,7 @@ export const improveHtmlAccessibility = (
calloutBlocks.forEach((block) => {
const aside = parsedDocument.createElement('aside');
aside.setAttribute('role', 'note');
aside.innerHTML = block.innerHTML;
moveChildNodes(block, aside);
block.replaceWith(aside);
});
@@ -303,7 +309,7 @@ export const improveHtmlAccessibility = (
}
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.
const checkbox = li.querySelector<HTMLInputElement>(
@@ -340,7 +346,7 @@ export const improveHtmlAccessibility = (
});
// Move content inside <code>.
code.innerHTML = block.innerHTML;
moveChildNodes(block, code);
pre.appendChild(code);
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.
await screen.findByTestId('modal-export-title');
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();
}, 10000);

View File

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