🐛(frontend) export any raster image supported by the browser to a PDF

WebP format isn't supported by react-pdf/renderer and so wasn't exported
properly, and some PNG images were also not exporting.
First drawing those raster images to a canvas and providing a dataURL to
react-pdf/renderer fixes those two bugs at once.

Signed-off-by: Mathieu Agopian <mathieu@agopian.info>
This commit is contained in:
Mathieu Agopian
2026-09-08 12:10:07 +02:00
committed by Anthony LC
parent 8ec3ae880f
commit a5f26434ba
6 changed files with 590 additions and 32 deletions
+1
View File
@@ -51,6 +51,7 @@ and this project adheres to
- 🐛(backend) manage async support for Docs custom middleware #2619
- 🐛(frontend) save the doc with a keepalive
request when leaving the page #2619
- 🐛(frontend) export any raster image supported by the browser to a PDF #2530
### Removed
@@ -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 <Image src>, 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 <Image>.
*/
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 <naturalPixelWidth>. 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,
@@ -0,0 +1,307 @@
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
// <Image src={x}> 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 { blockMappingImagePDF } from '../blocks-mapping/imagePDF';
import { DocsExporterPDF } from '../types';
import { convertBlobToPng, convertSvgToPng } from '../utils';
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',
name: '',
showPreview: true,
previewWidth: 0,
caption: '',
backgroundColor: 'default' as const,
textColor: 'default' as const,
textAlignment: 'left' as const,
...props,
},
children: [],
content: undefined,
};
}
function makeExporter(blob: Blob) {
return {
resolveFile: vi.fn().mockResolvedValue(blob),
} as unknown as DocsExporterPDF;
}
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<PDFElementProps> | undefined {
if (!React.isValidElement(node)) {
return undefined;
}
const el = node as React.ReactElement<PDFElementProps>;
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),
0,
);
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),
0,
);
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(['<svg/>'], { type: 'image/svg+xml' });
const result = await blockMappingImagePDF(
makeBlock(),
makeExporter(svgBlob),
0,
);
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' })),
0,
);
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),
0,
);
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(['<svg/>'], { type: 'image/svg+xml' });
await blockMappingImagePDF(
makeBlock({ previewWidth: 400 }),
makeExporter(svgBlob),
0,
);
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' })),
0,
);
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' })),
0,
);
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' })),
0,
);
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' })),
0,
);
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(['<svg/>'], { type: 'image/svg+xml' })),
0,
);
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),
0,
);
const textEl = findInTree(result as React.ReactNode, 'pdfText');
expect(textEl).toBeDefined();
expect(textEl!.props.children).toBe('A test caption');
});
});
@@ -0,0 +1,151 @@
import { Canvg } from 'canvg';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { convertBlobToPng, convertSvgToPng } from '../utils';
// Canvg uses canvas internally, which jsdom doesn't support. Mock it so we
// can verify the arguments it receives without actually rendering.
// vi.mock is hoisted by Vitest, so placement after imports does not affect behavior.
vi.mock('canvg', () => ({
Canvg: {
fromString: vi.fn(),
},
}));
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<typeof original>)
: 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<typeof vi.fn>;
render: ReturnType<typeof vi.fn>;
};
}
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(
'<svg width="300" height="150"></svg>',
);
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('<svg viewBox="0 0 200 100"></svg>');
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('<svg width="300" height="150"></svg>', 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('<svg></svg>');
expect(svgInstance().resize).toHaveBeenCalledWith(536, undefined, true);
expect(result.width).toBe(536);
});
it('calls svg.render() to draw to canvas', async () => {
await convertSvgToPng('<svg width="100" height="50"></svg>');
expect(svgInstance().render).toHaveBeenCalled();
});
});
@@ -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 <View wrap={false} />;
}
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);
}
if (!dimensions) {
try {
if (blob.type.includes('svg')) {
const svgText = await blob.text();
result = await convertSvgToPng(svgText, previewWidth);
} else {
result = await convertBlobToPng(blob, previewWidth);
}
} catch {
return <View wrap={false} />;
}
const { width, height } = dimensions;
if (!result || !result.png) {
return <View wrap={false} />;
}
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 (
<View wrap={false}>
<Image
src={pngConverted || blob}
src={result.png}
style={{
width: finalWidth * PIXELS_PER_POINT,
height: finalHeight * PIXELS_PER_POINT,
@@ -54,21 +70,6 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
);
};
async function getImageDimensions(blob: Blob) {
if (typeof window !== 'undefined') {
const url = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = url;
return new Promise<{ width: number; height: number }>((resolve) => {
img.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
});
}
}
function caption(
props: Partial<DefaultProps & { caption: string; previewWidth: number }>,
) {
@@ -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,64 @@ 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<DefaultProps>,
colors: typeof COLORS_DEFAULT,