diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/PresenterPrintDocument.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/PresenterPrintDocument.spec.tsx
new file mode 100644
index 000000000..cd9499cba
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/PresenterPrintDocument.spec.tsx
@@ -0,0 +1,80 @@
+import { render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import {
+ PRESENTER_PRINT_CONTENT_CLASS,
+ PRESENTER_PRINT_CSS,
+ PRESENTER_PRINT_LOGO_CLASS,
+ PRESENTER_PRINT_PAGE_CLASS,
+ PRESENTER_PRINT_PAGE_SELECTOR,
+ PRESENTER_PRINT_TITLE_CLASS,
+ PresenterPrintDocument,
+} from '../components/PresenterPrintDocument';
+import type { PresenterBlock } from '../types';
+
+const para = (text: string): PresenterBlock => ({
+ children: [],
+ content: [{ type: 'text', text, styles: {} }],
+ id: 'paragraph',
+ props: {
+ backgroundColor: 'default',
+ textAlignment: 'left',
+ textColor: 'default',
+ },
+ type: 'paragraph',
+});
+
+describe('PresenterPrintDocument', () => {
+ beforeEach(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ configurable: true,
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ addEventListener: vi.fn(),
+ addListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ matches: false,
+ media: query,
+ onchange: null,
+ removeEventListener: vi.fn(),
+ removeListener: vi.fn(),
+ })),
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ test('renders one print page per slide', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(
+ container.querySelectorAll(PRESENTER_PRINT_PAGE_SELECTOR),
+ ).toHaveLength(4);
+ expect(
+ container.querySelector(`.${PRESENTER_PRINT_TITLE_CLASS}`),
+ ).toHaveTextContent('Deck title');
+ expect(
+ container.querySelectorAll(`.${PRESENTER_PRINT_LOGO_CLASS}`),
+ ).toHaveLength(4);
+ });
+
+ test('centers short slide content vertically in print pages', () => {
+ expect(PRESENTER_PRINT_CSS).toContain(`.${PRESENTER_PRINT_PAGE_CLASS} {`);
+ expect(PRESENTER_PRINT_CSS).toContain('justify-content: safe center;');
+ expect(PRESENTER_PRINT_CSS).toContain(
+ `.${PRESENTER_PRINT_CONTENT_CLASS} {`,
+ );
+ expect(PRESENTER_PRINT_CSS).toContain('max-height: 100%;');
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx
index b4421eff9..e8f73f076 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx
@@ -3,6 +3,7 @@ import { DropdownMenu, DropdownMenuItem } from '@gouvfr-lasuite/ui-kit';
import {
ChevronLeft,
ChevronRight,
+ Download,
Link,
Maximize,
Minimize,
@@ -22,8 +23,10 @@ interface PresenterFloatingBarProps {
onPrev: () => void;
onNext: () => void;
onCopyLink: () => void;
+ onExportPdf: () => void;
onToggleFullscreen: () => void;
onClose: () => void;
+ isExportingPdf: boolean;
}
const barCss = css`
@@ -80,8 +83,10 @@ export const PresenterFloatingBar = ({
onPrev,
onNext,
onCopyLink,
+ onExportPdf,
onToggleFullscreen,
onClose,
+ isExportingPdf,
}: PresenterFloatingBarProps) => {
const { t } = useTranslation();
const isFirst = index <= 0;
@@ -118,8 +123,14 @@ export const PresenterFloatingBar = ({
icon: ,
callback: onCopyLink,
},
+ {
+ label: t('Download PDF'),
+ icon: ,
+ callback: onExportPdf,
+ isDisabled: isExportingPdf,
+ },
],
- [onCopyLink, t],
+ [isExportingPdf, onCopyLink, onExportPdf, t],
);
return (
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx
index 6b51687fa..797d60165 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx
@@ -15,6 +15,7 @@ import { useCopyPresenterLink } from '../hooks/useCopyPresenterLink';
import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts';
import { getSlideTitle, useSlides } from '../hooks/useSlides';
import type { PresenterBlock, PresenterSlideData } from '../types';
+import { printPresenterSlides } from '../utils_print';
import { PresenterDocsLogo } from './PresenterDocsLogo';
import { PresenterFloatingBar } from './PresenterFloatingBar';
@@ -86,6 +87,8 @@ export const PresenterOverlay = ({
],
[contentSlides, title],
);
+ const [isExportingPdf, setIsExportingPdf] = useState(false);
+
const total = slides.length;
const [currentIndex, setCurrentIndex] = useState(() =>
clampSlideIndex(initialSlideIndex, total),
@@ -128,6 +131,18 @@ export const PresenterOverlay = ({
() => setCurrentIndex(clamp(total - 1)),
[clamp, total],
);
+ const exportPdf = useCallback(async () => {
+ if (isExportingPdf) {
+ return;
+ }
+
+ setIsExportingPdf(true);
+ try {
+ await printPresenterSlides(slides);
+ } finally {
+ setIsExportingPdf(false);
+ }
+ }, [isExportingPdf, slides]);
const { isFullscreen, enter, exitIfOwned, toggle } = useBrowserFullscreen();
@@ -214,6 +229,8 @@ export const PresenterOverlay = ({
onPrev={goPrev}
onNext={goNext}
onCopyLink={() => copyPresenterLink(currentIndex)}
+ onExportPdf={() => void exportPdf()}
+ isExportingPdf={isExportingPdf}
onToggleFullscreen={() => void toggle()}
onClose={onClose}
/>
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterPrintDocument.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterPrintDocument.tsx
new file mode 100644
index 000000000..3899e86ba
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterPrintDocument.tsx
@@ -0,0 +1,169 @@
+import { PRESENTER_SLIDE_DESIGN_WIDTH } from '../constants';
+import { PresenterSlideData } from '../types';
+
+import { PresenterDocsLogo } from './PresenterDocsLogo';
+import { PresenterSlideContent } from './PresenterSlideContent';
+
+interface PresenterPrintDocumentProps {
+ slides: PresenterSlideData[];
+}
+
+export const PRESENTER_PRINT_ROOT_ID = 'presenter-print-root';
+export const PRESENTER_PRINT_STYLES_ID = 'presenter-print-styles';
+export const PRESENTER_PRINT_PAGE_SELECTOR = '[data-presenter-print-page]';
+export const PRESENTER_PRINT_PAGE_CLASS = '--docs--presenter-print-page';
+export const PRESENTER_PRINT_CONTENT_CLASS = '--docs--presenter-print-content';
+export const PRESENTER_PRINT_LOGO_CLASS = '--docs--presenter-print-logo';
+export const PRESENTER_PRINT_TITLE_CLASS = '--docs--presenter-print-title';
+
+export const PRESENTER_PRINT_CSS = `
+@media screen {
+ #${PRESENTER_PRINT_ROOT_ID} {
+ position: fixed;
+ top: 0;
+ left: -100000px;
+ width: 297mm;
+ min-height: 210mm;
+ overflow: hidden;
+ opacity: 0;
+ pointer-events: none;
+ }
+}
+
+@media print {
+ @page {
+ size: A4 landscape;
+ margin: 0;
+ }
+
+ html,
+ body {
+ width: auto !important;
+ height: auto !important;
+ min-height: 0 !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: visible !important;
+ background: white !important;
+ }
+
+ body > *:not(#${PRESENTER_PRINT_ROOT_ID}) {
+ display: none !important;
+ }
+
+ #${PRESENTER_PRINT_ROOT_ID} {
+ display: block !important;
+ position: static !important;
+ width: auto !important;
+ min-height: 0 !important;
+ opacity: 1 !important;
+ overflow: visible !important;
+ pointer-events: auto !important;
+ background: white !important;
+ }
+
+ .${PRESENTER_PRINT_PAGE_CLASS} {
+ position: relative;
+ width: 297mm;
+ height: 210mm;
+ box-sizing: border-box;
+ padding: 18mm 24mm;
+ display: flex;
+ flex-direction: column;
+ justify-content: safe center;
+ overflow: hidden !important;
+ background: white !important;
+ break-after: page;
+ page-break-after: always;
+ break-inside: avoid;
+ page-break-inside: avoid;
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+
+ .${PRESENTER_PRINT_PAGE_CLASS}:last-child {
+ break-after: auto;
+ page-break-after: auto;
+ }
+
+ .${PRESENTER_PRINT_CONTENT_CLASS} {
+ width: ${PRESENTER_SLIDE_DESIGN_WIDTH}px;
+ max-width: 100%;
+ max-height: 100%;
+ margin: 0 auto;
+ overflow: hidden !important;
+ }
+
+ .${PRESENTER_PRINT_CONTENT_CLASS} .bn-editor,
+ .${PRESENTER_PRINT_CONTENT_CLASS} .bn-root,
+ .${PRESENTER_PRINT_CONTENT_CLASS} .ProseMirror {
+ min-height: 0 !important;
+ }
+
+ .${PRESENTER_PRINT_CONTENT_CLASS} .bn-editor {
+ padding: 0 !important;
+ }
+
+ .${PRESENTER_PRINT_CONTENT_CLASS} [data-content-type="file"] .bn-file-block-content-wrapper,
+ .${PRESENTER_PRINT_CONTENT_CLASS} [data-content-type="pdf"] .bn-file-block-content-wrapper,
+ .${PRESENTER_PRINT_CONTENT_CLASS} [data-content-type="audio"] .bn-file-block-content-wrapper,
+ .${PRESENTER_PRINT_CONTENT_CLASS} [data-content-type="video"] .bn-file-block-content-wrapper {
+ display: none !important;
+ }
+
+ .${PRESENTER_PRINT_CONTENT_CLASS} .print-url-label {
+ text-decoration: none !important;
+ }
+
+ .${PRESENTER_PRINT_CONTENT_CLASS} * {
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ }
+
+ .${PRESENTER_PRINT_LOGO_CLASS} {
+ position: absolute;
+ bottom: 16px;
+ left: 16px;
+ width: 80px;
+ height: 32px;
+ }
+
+ .${PRESENTER_PRINT_TITLE_CLASS} {
+ width: ${PRESENTER_SLIDE_DESIGN_WIDTH}px;
+ max-width: 100%;
+ margin: 0 auto;
+ color: var(--c--contextuals--content--semantic--neutral--primary, #222631);
+ font-size: 40px;
+ font-weight: 700;
+ line-height: 48px;
+ text-align: center;
+ overflow-wrap: anywhere;
+ }
+}
+`;
+
+export const PresenterPrintDocument = ({
+ slides,
+}: PresenterPrintDocumentProps) => (
+
+ {slides.map((slide, index) => (
+
+ {slide.kind === 'title' ? (
+ {slide.title}
+ ) : (
+
+ )}
+
+
+ ))}
+
+);
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/utils_print.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/utils_print.tsx
new file mode 100644
index 000000000..a84dcc493
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/utils_print.tsx
@@ -0,0 +1,150 @@
+import { Root, createRoot } from 'react-dom/client';
+
+import {
+ wrapInterlinksWithAnchor,
+ wrapMediaWithLink,
+} from '@/docs/doc-export/utils_print';
+
+import {
+ PRESENTER_PRINT_CSS,
+ PRESENTER_PRINT_ROOT_ID,
+ PRESENTER_PRINT_STYLES_ID,
+ PresenterPrintDocument,
+} from './components/PresenterPrintDocument';
+import { PresenterSlideData } from './types';
+
+const PRINT_CLEANUP_DELAY_MS = 10000;
+const PRINT_IMAGE_WAIT_TIMEOUT_MS = 2000;
+
+interface PresenterPrintMount {
+ cleanup: () => void;
+ container: HTMLDivElement;
+ root: Root;
+}
+
+const nextFrame = () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+
+const timeout = (ms: number) =>
+ new Promise((resolve) => {
+ window.setTimeout(resolve, ms);
+ });
+
+const removeExistingPrintArtifacts = () => {
+ document.getElementById(PRESENTER_PRINT_ROOT_ID)?.remove();
+ document.getElementById(PRESENTER_PRINT_STYLES_ID)?.remove();
+};
+
+const createPrintStyleElement = () => {
+ const style = document.createElement('style');
+ style.id = PRESENTER_PRINT_STYLES_ID;
+ style.textContent = PRESENTER_PRINT_CSS;
+ return style;
+};
+
+const waitForImages = async (container: HTMLElement) => {
+ const images = Array.from(container.querySelectorAll('img'));
+ if (images.length === 0) {
+ return;
+ }
+
+ const imagePromises = images.map(
+ (image) =>
+ new Promise((resolve) => {
+ if (image.complete) {
+ resolve();
+ return;
+ }
+
+ const done = () => {
+ image.removeEventListener('load', done);
+ image.removeEventListener('error', done);
+ resolve();
+ };
+
+ image.addEventListener('load', done, { once: true });
+ image.addEventListener('error', done, { once: true });
+ }),
+ );
+
+ await Promise.race([
+ Promise.all(imagePromises).then(() => undefined),
+ timeout(PRINT_IMAGE_WAIT_TIMEOUT_MS),
+ ]);
+};
+
+export const mountPresenterPrintSlides = (
+ slides: PresenterSlideData[],
+): PresenterPrintMount => {
+ removeExistingPrintArtifacts();
+
+ const style = createPrintStyleElement();
+ const container = document.createElement('div');
+ container.id = PRESENTER_PRINT_ROOT_ID;
+
+ document.head.appendChild(style);
+ document.body.appendChild(container);
+
+ const root = createRoot(container);
+ root.render();
+
+ return {
+ container,
+ root,
+ cleanup: () => {
+ root.unmount();
+ container.remove();
+ style.remove();
+ },
+ };
+};
+
+// The slides are mounted off-screen via createRoot, then their content
+// (BlockNote/ProseMirror) lays out asynchronously. We wait two paint frames so
+// React commits the render and the editor settles before measuring images,
+// then one more after images load. Do NOT collapse these into a single frame:
+// printing too early yields blank pages. `printPresenterSlides` adds a final
+// frame after the media/interlink wrapping for the same reason.
+export const waitForPresenterPrintReady = async (container: HTMLElement) => {
+ await nextFrame();
+ await nextFrame();
+ await waitForImages(container);
+ await nextFrame();
+};
+
+export const printPresenterSlides = async (slides: PresenterSlideData[]) => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const { cleanup, container } = mountPresenterPrintSlides(slides);
+ const scopedCleanups: Array<() => void> = [];
+ let cleaned = false;
+ let fallbackTimer: number | undefined;
+ const cleanupOnce = () => {
+ if (cleaned) {
+ return;
+ }
+ cleaned = true;
+ window.clearTimeout(fallbackTimer);
+ window.removeEventListener('afterprint', cleanupOnce);
+ scopedCleanups.splice(0).forEach((scopedCleanup) => scopedCleanup());
+ cleanup();
+ };
+
+ try {
+ window.addEventListener('afterprint', cleanupOnce, { once: true });
+ await waitForPresenterPrintReady(container);
+ scopedCleanups.push(wrapInterlinksWithAnchor(container));
+ scopedCleanups.push(wrapMediaWithLink(container));
+ await nextFrame();
+ window.print();
+ // Fallback in case `afterprint` never fires (some browsers/headless).
+ fallbackTimer = window.setTimeout(cleanupOnce, PRINT_CLEANUP_DELAY_MS);
+ } catch (error) {
+ cleanupOnce();
+ throw error;
+ }
+};