diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aafeb4ab4..7a7bbceb4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to - ♿️(frontend) restore skip to content link after header redesign #2510 +### Fixed + +- 🐛(frontend) export any raster image supported by the browser to a PDF #2530 + ## [v5.4.1] - 2026-07-09 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index dcc8afc3db..bb49cc3f91 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -247,6 +247,46 @@ test.describe('Doc Export', () => { expect(pdfText.text).toContain('Hello World'); }); + /** + * Regression test for https://github.com/suitenumerique/docs/issues/860 + * + * PNG images were silently dropped from the exported PDF because the raw + * Blob was passed directly to @react-pdf/renderer's , which + * triggered a WASM "too many arguments" error internally and caused the + * image to be omitted. SVG images were unaffected because they were already + * converted to a data URL string before being passed to . + */ + test('it includes PNG images in the exported PDF', async ({ + page, + browserName, + }) => { + // overrideDocContent uploads both an SVG and a PNG image into the editor. + await overrideDocContent({ page, browserName }); + + await clickInEditorMenu(page, 'Download'); + + const downloadPromise = page.waitForEvent('download', (download) => + download.suggestedFilename().endsWith('.pdf'), + ); + + void page.getByTestId('doc-export-download-button').click(); + + const download = await downloadPromise; + await page.waitForTimeout(1000); + + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + + // Each embedded image in a PDF is stored as an XObject stream whose + // dictionary contains /Width . Emoji images are 64px + // wide, the SVG (test.svg, 100×100) becomes 100px. The uploaded PNG + // (logo-suite-numerique.png) is 756px wide — a value that won't appear + // for page sizes (A4 = 595 pt) or emoji. With the bug the PNG is silently + // dropped and /Width 756 is absent; after the fix it appears twice (image + // data stream + alpha-mask stream). + const pdfString = pdfBuffer.toString('latin1'); + expect(pdfString).toMatch(/\/Width 756/); + }); + test('it injects the correct language attribute into PDF export', async ({ page, browserName, diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/imagePDF.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/imagePDF.test.tsx new file mode 100644 index 0000000000..3fd7679a82 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/imagePDF.test.tsx @@ -0,0 +1,279 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Use string identifiers so vi.mock's factory doesn't reference any +// outer-scope variables (which would be undefined after hoisting). +// React accepts arbitrary strings as element types, so JSX like +// compiles to React.createElement(Image, { src: x }). +// Because Image is the string 'pdfImage', that call is equivalent to +// React.createElement('pdfImage', { src: x }), and the resulting +// element's .type is 'pdfImage' — which is what findInTree checks. +vi.mock('@react-pdf/renderer', () => ({ + Image: 'pdfImage', + Text: 'pdfText', + View: 'pdfView', +})); + +vi.mock('../utils', () => ({ + convertBlobToPng: vi.fn(), + convertSvgToPng: vi.fn(), +})); + +import { convertBlobToPng, convertSvgToPng } from '../utils'; +import { blockMappingImagePDF } from '../blocks-mapping/imagePDF'; + +const CANVAS_PNG_URL = 'data:image/png;base64,Y2FudmFz'; +const SVG_CONVERTED_URL = 'data:image/png;base64,c3Zn'; + +function makeBlock( + props: Partial<{ previewWidth: number; caption: string }> = {}, +) { + return { + id: 'test-block', + type: 'image' as const, + props: { + url: 'https://example.com/image.png', + previewWidth: undefined as number | undefined, + caption: '', + backgroundColor: 'default' as const, + textColor: 'default' as const, + textAlignment: 'left' as const, + ...props, + }, + children: [], + content: [], + }; +} + +function makeExporter(blob: Blob) { + return { resolveFile: vi.fn().mockResolvedValue(blob) }; +} + +type PDFElementProps = { + children?: React.ReactNode; + src?: string; + style?: { width?: number; height?: number }; +}; + +// Walk the React element tree to find the first node of a given type. +function findInTree( + node: React.ReactNode, + type: string, +): React.ReactElement | undefined { + if (!React.isValidElement(node)) return undefined; + const el = node as React.ReactElement; + if (el.type === type) return el; + const { children } = el.props; + if (!children) return undefined; + const arr = Array.isArray(children) ? children : [children]; + for (const child of arr) { + const found = findInTree(child, type); + if (found) return found; + } + return undefined; +} + +describe('blockMappingImagePDF', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns an empty View when the blob is not an image', async () => { + const blob = new Blob(['data'], { type: 'video/mp4' }); + const result = await blockMappingImagePDF(makeBlock(), makeExporter(blob)); + + expect(React.isValidElement(result)).toBe(true); + const element = result as React.ReactElement; + expect(element.type).toBe('pdfView'); + // Empty View has no Image child + expect(findInTree(element, 'pdfImage')).toBeUndefined(); + }); + + it('converts PNG to a canvas-derived PNG data URL (not a raw Blob) as Image src', async () => { + vi.mocked(convertBlobToPng).mockResolvedValue({ + png: CANVAS_PNG_URL, + width: 300, + height: 150, + }); + + const pngBlob = new Blob(['fake-png'], { type: 'image/png' }); + const result = await blockMappingImagePDF( + makeBlock(), + makeExporter(pngBlob), + ); + + const imageEl = findInTree(result as React.ReactNode, 'pdfImage'); + expect(imageEl).toBeDefined(); + expect(imageEl!.props.src).toBe(CANVAS_PNG_URL); + }); + + it('converts SVG images to PNG and passes the converted data URL as src', async () => { + vi.mocked(convertSvgToPng).mockResolvedValue({ + png: SVG_CONVERTED_URL, + width: 300, + height: 150, + }); + + const svgBlob = new Blob([''], { type: 'image/svg+xml' }); + const result = await blockMappingImagePDF( + makeBlock(), + makeExporter(svgBlob), + ); + + const imageEl = findInTree(result as React.ReactNode, 'pdfImage'); + expect(imageEl).toBeDefined(); + expect(imageEl!.props.src).toBe(SVG_CONVERTED_URL); + }); + + it('clamps previewWidth to MAX_WIDTH (600) before conversion and rendering', async () => { + // previewWidth=800 is clamped to 600 before being passed to convertBlobToPng. + // The converter returns 600×300 (already at the clamped size). + // Rendered: width = 600*PIXELS_PER_POINT(0.75) = 450, height = 300*PIXELS_PER_POINT(0.75) = 225. + vi.mocked(convertBlobToPng).mockResolvedValue({ + png: CANVAS_PNG_URL, + width: 600, + height: 300, + }); + + const result = await blockMappingImagePDF( + makeBlock({ previewWidth: 800 }), + makeExporter(new Blob(['fake-png'], { type: 'image/png' })), + ); + + expect(convertBlobToPng).toHaveBeenCalledWith(expect.anything(), 600); + const imageEl = findInTree(result as React.ReactNode, 'pdfImage'); + expect(imageEl).toBeDefined(); + expect(imageEl?.props.style?.width).toBe(450); + expect(imageEl?.props.style?.height).toBe(225); + }); + + it('passes previewWidth to convertBlobToPng so it can resize during transcoding', async () => { + vi.mocked(convertBlobToPng).mockResolvedValue({ + png: CANVAS_PNG_URL, + width: 400, + height: 200, + }); + + const pngBlob = new Blob(['fake-png'], { type: 'image/png' }); + await blockMappingImagePDF( + makeBlock({ previewWidth: 400 }), + makeExporter(pngBlob), + ); + + expect(convertBlobToPng).toHaveBeenCalledWith(pngBlob, 400); + }); + + it('passes previewWidth to convertSvgToPng so it can resize during transcoding', async () => { + vi.mocked(convertSvgToPng).mockResolvedValue({ + png: SVG_CONVERTED_URL, + width: 400, + height: 200, + }); + + const svgBlob = new Blob([''], { type: 'image/svg+xml' }); + await blockMappingImagePDF( + makeBlock({ previewWidth: 400 }), + makeExporter(svgBlob), + ); + + expect(convertSvgToPng).toHaveBeenCalledWith(expect.any(String), 400); + }); + + it('uses natural image dimensions for the rendered style when no previewWidth is set', async () => { + // naturalWidth=300, naturalHeight=150 → finalWidth=300, finalHeight=150. + // Rendered: width = 300*PIXELS_PER_POINT(0.75) = 225, height = 150*PIXELS_PER_POINT(0.75) = 112.5. + vi.mocked(convertBlobToPng).mockResolvedValue({ + png: CANVAS_PNG_URL, + width: 300, + height: 150, + }); + + const result = await blockMappingImagePDF( + makeBlock(), + makeExporter(new Blob(['fake-png'], { type: 'image/png' })), + ); + + const imageEl = findInTree(result as React.ReactNode, 'pdfImage'); + expect(imageEl).toBeDefined(); + expect(imageEl!.props.style.width).toBe(225); + expect(imageEl!.props.style.height).toBe(112.5); + }); + + it('scales rendered style to previewWidth when it is within MAX_WIDTH', async () => { + // previewWidth=400 (< MAX_WIDTH=600), natural size 300×150. + // finalWidth=400, finalHeight=(400/300)*150≈200. + // Rendered: width = 400*PIXELS_PER_POINT(0.75) = 300, height ≈ 200*PIXELS_PER_POINT(0.75) = 150. + vi.mocked(convertBlobToPng).mockResolvedValue({ + png: CANVAS_PNG_URL, + width: 300, + height: 150, + }); + + const result = await blockMappingImagePDF( + makeBlock({ previewWidth: 400 }), + makeExporter(new Blob(['fake-png'], { type: 'image/png' })), + ); + + const imageEl = findInTree(result as React.ReactNode, 'pdfImage'); + expect(imageEl).toBeDefined(); + expect(imageEl!.props.style.width).toBe(300); + expect(imageEl!.props.style.height).toBe(150); + }); + + it('returns an empty View when convertBlobToPng returns undefined', async () => { + vi.mocked(convertBlobToPng).mockResolvedValue(undefined); + + const result = await blockMappingImagePDF( + makeBlock(), + makeExporter(new Blob(['fake-png'], { type: 'image/png' })), + ); + + const element = result as React.ReactElement; + expect(element.type).toBe('pdfView'); + expect(findInTree(element, 'pdfImage')).toBeUndefined(); + }); + + it('returns an empty View when convertBlobToPng throws', async () => { + vi.mocked(convertBlobToPng).mockRejectedValue(new Error('canvas error')); + + const result = await blockMappingImagePDF( + makeBlock(), + makeExporter(new Blob(['fake-png'], { type: 'image/png' })), + ); + + const element = result as React.ReactElement; + expect(element.type).toBe('pdfView'); + expect(findInTree(element, 'pdfImage')).toBeUndefined(); + }); + + it('returns an empty View when convertSvgToPng throws', async () => { + vi.mocked(convertSvgToPng).mockRejectedValue(new Error('canvas error')); + + const result = await blockMappingImagePDF( + makeBlock(), + makeExporter(new Blob([''], { type: 'image/svg+xml' })), + ); + + const element = result as React.ReactElement; + expect(element.type).toBe('pdfView'); + expect(findInTree(element, 'pdfImage')).toBeUndefined(); + }); + + it('renders a caption when caption prop is set', async () => { + vi.mocked(convertBlobToPng).mockResolvedValue({ + png: CANVAS_PNG_URL, + width: 300, + height: 150, + }); + + const pngBlob = new Blob(['fake-png'], { type: 'image/png' }); + const result = await blockMappingImagePDF( + makeBlock({ caption: 'A test caption' }), + makeExporter(pngBlob), + ); + + const textEl = findInTree(result as React.ReactNode, 'pdfText'); + expect(textEl).toBeDefined(); + expect(textEl!.props.children).toBe('A test caption'); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utils.test.ts b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utils.test.ts new file mode 100644 index 0000000000..fc49a6df5a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utils.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Canvg uses canvas internally, which jsdom doesn't support. Mock it so we +// can verify the arguments it receives without actually rendering. +vi.mock('canvg', () => ({ + Canvg: { + fromString: vi.fn(), + }, +})); + +import { Canvg } from 'canvg'; +import { convertBlobToPng, convertSvgToPng } from '../utils'; + +const CANVAS_PNG_URL = 'data:image/png;base64,Y2FudmFz'; + +// jsdom doesn't implement createImageBitmap or a working canvas. These stubs +// let the functions under test run to completion with predictable results. +function stubCanvas(bitmapWidth = 400, bitmapHeight = 200) { + const bmp = { width: bitmapWidth, height: bitmapHeight, close: vi.fn() }; + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bmp)); + + const canvas = { + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue({ drawImage: vi.fn() }), + toDataURL: vi.fn().mockReturnValue(CANVAS_PNG_URL), + }; + const original = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation( + (tagName: string, options?: ElementCreationOptions | undefined) => + tagName === 'canvas' + ? // @ts-ignore + (canvas as ReturnType) + : original(tagName, options), + ); + + return { bmp, canvas }; +} + +describe('convertBlobToPng', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('transcodes a blob to a PNG data URL and returns it with its dimensions', async () => { + stubCanvas(400, 200); + + const result = await convertBlobToPng( + new Blob(['fake'], { type: 'image/png' }), + ); + + expect(result).toEqual({ png: CANVAS_PNG_URL, width: 400, height: 200 }); + }); + + it('resizes the canvas to the requested width, preserving aspect ratio', async () => { + // bitmap 400×200 (ratio 0.5), requested width=200 → height=100 + const { canvas } = stubCanvas(400, 200); + + const result = await convertBlobToPng( + new Blob(['fake'], { type: 'image/png' }), + 200, + ); + + expect(canvas.width).toBe(200); + expect(canvas.height).toBe(100); + expect(result).toEqual({ png: CANVAS_PNG_URL, width: 200, height: 100 }); + }); + + it('draws the image scaled to the canvas dimensions', async () => { + const { canvas } = stubCanvas(400, 200); + + await convertBlobToPng(new Blob(['fake'], { type: 'image/png' }), 200); + + const ctx = canvas.getContext.mock.results[0].value; + expect(ctx.drawImage).toHaveBeenCalledWith( + expect.anything(), + 0, + 0, + 200, + 100, + ); + }); + + it('closes the ImageBitmap after drawing', async () => { + const { bmp } = stubCanvas(400, 200); + + await convertBlobToPng(new Blob(['fake'], { type: 'image/png' })); + + expect(bmp.close).toHaveBeenCalled(); + }); +}); + +describe('convertSvgToPng', () => { + // Returns the Canvg instance created by the most recent convertSvgToPng call. + function svgInstance() { + return vi.mocked(Canvg.fromString).mock.results[0].value as { + resize: ReturnType; + render: ReturnType; + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + stubCanvas(); + vi.mocked(Canvg.fromString).mockReturnValue({ + resize: vi.fn(), + render: vi.fn().mockResolvedValue(undefined), + } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('returns a PNG data URL with the SVG natural dimensions from width/height attributes', async () => { + const result = await convertSvgToPng( + '', + ); + + expect(result).toEqual({ png: CANVAS_PNG_URL, width: 300, height: 150 }); + }); + + it('reads dimensions from the viewBox attribute when width/height are absent', async () => { + const result = await convertSvgToPng(''); + + expect(result).toEqual({ png: CANVAS_PNG_URL, width: 200, height: 100 }); + }); + + it('resizes to the given width, preserving the SVG aspect ratio', async () => { + // SVG is 300×150 (ratio 0.5), requested width=600 → height=300 + await convertSvgToPng('', 600); + + expect(svgInstance().resize).toHaveBeenCalledWith(600, 300, true); + }); + + it('uses FALLBACK_WIDTH (536) when the SVG has no dimensions and no width is given', async () => { + const result = await convertSvgToPng(''); + + expect(svgInstance().resize).toHaveBeenCalledWith(536, undefined, true); + expect(result.width).toBe(536); + }); + + it('calls svg.render() to draw to canvas', async () => { + await convertSvgToPng(''); + + expect(svgInstance().render).toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx index 1471e93341..9db1237a3f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx @@ -2,37 +2,53 @@ import { DefaultProps } from '@blocknote/core'; import { Image, Text, View } from '@react-pdf/renderer'; import { DocsExporterPDF } from '../types'; -import { convertSvgToPng } from '../utils'; +import { convertBlobToPng, convertSvgToPng } from '../utils'; const PIXELS_PER_POINT = 0.75; const FONT_SIZE = 16; const MAX_WIDTH = 600; +/** + * Renders an image block as a PDF element. + * + * Resolves the image file, transcodes it to a PNG data URL via canvas (so + * that formats unsupported by @react-pdf/renderer such as WebP are handled), + * computes the final display dimensions (clamped to MAX_WIDTH), and returns a + * react-pdf View containing the Image and an optional caption. + * + * @param block - The image block, including the image URL, optional previewWidth, and caption. + * @param exporter - The PDF exporter, used to resolve the image URL to a Blob. + * @returns A react-pdf View element, or an empty View when the blob is not an + * image or the conversion fails. + */ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['image'] = async (block, exporter) => { const blob = await exporter.resolveFile(block.props.url); - let pngConverted: string | undefined; - let dimensions: { width: number; height: number } | undefined; - let previewWidth = block.props.previewWidth || undefined; + let result: { png: string; width: number; height: number } | undefined; + const previewWidth = block.props.previewWidth + ? Math.min(block.props.previewWidth, MAX_WIDTH) + : undefined; if (!blob.type.includes('image')) { return ; } - if (blob.type.includes('svg')) { - const svgText = await blob.text(); - const result = await convertSvgToPng(svgText, previewWidth); - pngConverted = result.png; - dimensions = { width: result.width, height: result.height }; - } else { - dimensions = await getImageDimensions(blob); + try { + if (blob.type.includes('svg')) { + const svgText = await blob.text(); + result = await convertSvgToPng(svgText, previewWidth); + } else { + result = await convertBlobToPng(blob, previewWidth); + } + } catch { + return ; } - if (!dimensions) { + if (!result || !result.png) { return ; } - const { width, height } = dimensions; + const { width, height } = result; // Ensure the final width never exceeds MAX_WIDTH to prevent images // from overflowing the page width in the exported document @@ -42,7 +58,7 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping'][' return ( ((resolve) => { - img.onload = () => { - URL.revokeObjectURL(url); - resolve({ width: img.naturalWidth, height: img.naturalHeight }); - }; - }); - } -} - function caption( props: Partial, ) { 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 72992ea2a1..23ac225d85 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 @@ -75,7 +75,7 @@ export async function convertSvgToPng( await svg.render(); const returnWidth = width || originalWidth || FALLBACK_WIDTH; - const returnHeight = calculatedHeight || returnWidth; + const returnHeight = calculatedHeight || originalHeight || returnWidth; return { png: canvas.toDataURL('image/png'), @@ -84,6 +84,62 @@ export async function convertSvgToPng( }; } +/** + * Converts any raster format (PNG, WebP, JPEG, ...) into a PNG data URL via canvas. + * + * This function creates a canvas, draws the image to it, and returns its data URL and size. + * + * @param {Blob} blob - The raw raster image to convert. + * @param {number} width - The desired width of the output PNG (height is auto-calculated to preserve aspect ratio). + * @returns {Promise<{ png: string; width: number; height: number }>} A Promise that resolves to an object containing the PNG data URL and its dimensions. + * + * @throws Will throw an error if the canvas context cannot be initialized. + */ +// Convert any raster format (PNG, WebP, JPEG, …) to a PNG data URL via +// canvas. This has two benefits: +// 1. Formats unsupported by @react-pdf/renderer (e.g. WebP) are +// transcoded to PNG which react-pdf can embed. +// 2. Passing a raw Blob to react-pdf triggers a WASM "too many +// arguments" error in its internal image pipeline; a canvas-derived +// data URL sidesteps that entirely. +export async function convertBlobToPng( + blob: Blob, + width?: number, +): Promise<{ png: string; width: number; height: number } | undefined> { + if (typeof window === 'undefined') return; + const bmp = await createImageBitmap(blob); + try { + const canvas = document.createElement('canvas'); + + let calculatedHeight: number | undefined; + // Resize if width provided, preserving aspect ratio + if (width) { + canvas.width = width; + const aspectRatio = bmp.height / bmp.width; + calculatedHeight = Math.round(width * aspectRatio); + canvas.height = calculatedHeight; + } else { + canvas.width = bmp.width; + canvas.height = bmp.height; + } + + const ctx = canvas.getContext('2d', { + alpha: true, + }); + if (!ctx) { + throw new Error('Canvas context is null'); + } + ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height); + return { + png: canvas.toDataURL('image/png'), + width: canvas.width, + height: canvas.height, + }; + } finally { + bmp.close(); + } +} + export function docxBlockPropsToStyles( props: Partial, colors: typeof COLORS_DEFAULT,