Skip to content

Commit 9a6eec0

Browse files
committed
🐛(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>
1 parent 8d2dd5b commit 9a6eec0

5 files changed

Lines changed: 553 additions & 29 deletions

File tree

src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,46 @@ test.describe('Doc Export', () => {
247247
expect(pdfText.text).toContain('Hello World');
248248
});
249249

250+
/**
251+
* Regression test for https://github.com/suitenumerique/docs/issues/860
252+
*
253+
* PNG images were silently dropped from the exported PDF because the raw
254+
* Blob was passed directly to @react-pdf/renderer's <Image src>, which
255+
* triggered a WASM "too many arguments" error internally and caused the
256+
* image to be omitted. SVG images were unaffected because they were already
257+
* converted to a data URL string before being passed to <Image>.
258+
*/
259+
test('it includes PNG images in the exported PDF', async ({
260+
page,
261+
browserName,
262+
}) => {
263+
// overrideDocContent uploads both an SVG and a PNG image into the editor.
264+
await overrideDocContent({ page, browserName });
265+
266+
await clickInEditorMenu(page, 'Download');
267+
268+
const downloadPromise = page.waitForEvent('download', (download) =>
269+
download.suggestedFilename().endsWith('.pdf'),
270+
);
271+
272+
void page.getByTestId('doc-export-download-button').click();
273+
274+
const download = await downloadPromise;
275+
await page.waitForTimeout(1000);
276+
277+
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
278+
279+
// Each embedded image in a PDF is stored as an XObject stream whose
280+
// dictionary contains /Width <naturalPixelWidth>. Emoji images are 64px
281+
// wide, the SVG (test.svg, 100×100) becomes 100px. The uploaded PNG
282+
// (logo-suite-numerique.png) is 756px wide — a value that won't appear
283+
// for page sizes (A4 = 595 pt) or emoji. With the bug the PNG is silently
284+
// dropped and /Width 756 is absent; after the fix it appears twice (image
285+
// data stream + alpha-mask stream).
286+
const pdfString = pdfBuffer.toString('latin1');
287+
expect(pdfString).toMatch(/\/Width 756/);
288+
});
289+
250290
test('it injects the correct language attribute into PDF export', async ({
251291
page,
252292
browserName,
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import React from 'react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
// Use string identifiers so vi.mock's factory doesn't reference any
5+
// outer-scope variables (which would be undefined after hoisting).
6+
// React accepts arbitrary strings as element types, so JSX like
7+
// <Image src={x}> compiles to React.createElement(Image, { src: x }).
8+
// Because Image is the string 'pdfImage', that call is equivalent to
9+
// React.createElement('pdfImage', { src: x }), and the resulting
10+
// element's .type is 'pdfImage' — which is what findInTree checks.
11+
vi.mock('@react-pdf/renderer', () => ({
12+
Image: 'pdfImage',
13+
Text: 'pdfText',
14+
View: 'pdfView',
15+
}));
16+
17+
vi.mock('../utils', () => ({
18+
convertBlobToPng: vi.fn(),
19+
convertSvgToPng: vi.fn(),
20+
}));
21+
22+
import { convertBlobToPng, convertSvgToPng } from '../utils';
23+
import { blockMappingImagePDF } from '../blocks-mapping/imagePDF';
24+
25+
const CANVAS_PNG_URL = 'data:image/png;base64,Y2FudmFz';
26+
const SVG_CONVERTED_URL = 'data:image/png;base64,c3Zn';
27+
28+
function makeBlock(
29+
props: Partial<{ previewWidth: number; caption: string }> = {},
30+
) {
31+
return {
32+
id: 'test-block',
33+
type: 'image' as const,
34+
props: {
35+
url: 'https://example.com/image.png',
36+
previewWidth: undefined as number | undefined,
37+
caption: '',
38+
backgroundColor: 'default' as const,
39+
textColor: 'default' as const,
40+
textAlignment: 'left' as const,
41+
...props,
42+
},
43+
children: [],
44+
content: [],
45+
};
46+
}
47+
48+
function makeExporter(blob: Blob) {
49+
return { resolveFile: vi.fn().mockResolvedValue(blob) };
50+
}
51+
52+
type PDFElementProps = {
53+
children?: React.ReactNode;
54+
src?: string;
55+
style?: { width?: number; height?: number };
56+
};
57+
58+
// Walk the React element tree to find the first node of a given type.
59+
function findInTree(
60+
node: React.ReactNode,
61+
type: string,
62+
): React.ReactElement<PDFElementProps> | undefined {
63+
if (!React.isValidElement(node)) return undefined;
64+
const el = node as React.ReactElement<PDFElementProps>;
65+
if (el.type === type) return el;
66+
const { children } = el.props;
67+
if (!children) return undefined;
68+
const arr = Array.isArray(children) ? children : [children];
69+
for (const child of arr) {
70+
const found = findInTree(child, type);
71+
if (found) return found;
72+
}
73+
return undefined;
74+
}
75+
76+
describe('blockMappingImagePDF', () => {
77+
beforeEach(() => {
78+
vi.clearAllMocks();
79+
});
80+
81+
it('returns an empty View when the blob is not an image', async () => {
82+
const blob = new Blob(['data'], { type: 'video/mp4' });
83+
const result = await blockMappingImagePDF(makeBlock(), makeExporter(blob));
84+
85+
expect(React.isValidElement(result)).toBe(true);
86+
const element = result as React.ReactElement;
87+
expect(element.type).toBe('pdfView');
88+
// Empty View has no Image child
89+
expect(findInTree(element, 'pdfImage')).toBeUndefined();
90+
});
91+
92+
it('converts PNG to a canvas-derived PNG data URL (not a raw Blob) as Image src', async () => {
93+
vi.mocked(convertBlobToPng).mockResolvedValue({
94+
png: CANVAS_PNG_URL,
95+
width: 300,
96+
height: 150,
97+
});
98+
99+
const pngBlob = new Blob(['fake-png'], { type: 'image/png' });
100+
const result = await blockMappingImagePDF(
101+
makeBlock(),
102+
makeExporter(pngBlob),
103+
);
104+
105+
const imageEl = findInTree(result as React.ReactNode, 'pdfImage');
106+
expect(imageEl).toBeDefined();
107+
expect(imageEl!.props.src).toBe(CANVAS_PNG_URL);
108+
});
109+
110+
it('converts SVG images to PNG and passes the converted data URL as src', async () => {
111+
vi.mocked(convertSvgToPng).mockResolvedValue({
112+
png: SVG_CONVERTED_URL,
113+
width: 300,
114+
height: 150,
115+
});
116+
117+
const svgBlob = new Blob(['<svg/>'], { type: 'image/svg+xml' });
118+
const result = await blockMappingImagePDF(
119+
makeBlock(),
120+
makeExporter(svgBlob),
121+
);
122+
123+
const imageEl = findInTree(result as React.ReactNode, 'pdfImage');
124+
expect(imageEl).toBeDefined();
125+
expect(imageEl!.props.src).toBe(SVG_CONVERTED_URL);
126+
});
127+
128+
it('clamps image width to MAX_WIDTH (600) when previewWidth exceeds it', async () => {
129+
// convertBlobToPng reports natural size 300×150.
130+
// previewWidth=800 → clamped to MAX_WIDTH=600.
131+
// finalHeight = (600/300)*150 = 300.
132+
// Rendered: width = 600*PIXELS_PER_POINT(0.75) = 450, height = 300*PIXELS_PER_POINT(0.75) = 225.
133+
vi.mocked(convertBlobToPng).mockResolvedValue({
134+
png: CANVAS_PNG_URL,
135+
width: 300,
136+
height: 150,
137+
});
138+
139+
const result = await blockMappingImagePDF(
140+
makeBlock({ previewWidth: 800 }),
141+
makeExporter(new Blob(['fake-png'], { type: 'image/png' })),
142+
);
143+
144+
const imageEl = findInTree(result as React.ReactNode, 'pdfImage');
145+
expect(imageEl).toBeDefined();
146+
expect(imageEl!.props.style.width).toBe(450);
147+
expect(imageEl!.props.style.height).toBe(225);
148+
});
149+
150+
it('passes previewWidth to convertBlobToPng so it can resize during transcoding', async () => {
151+
vi.mocked(convertBlobToPng).mockResolvedValue({
152+
png: CANVAS_PNG_URL,
153+
width: 400,
154+
height: 200,
155+
});
156+
157+
const pngBlob = new Blob(['fake-png'], { type: 'image/png' });
158+
await blockMappingImagePDF(
159+
makeBlock({ previewWidth: 400 }),
160+
makeExporter(pngBlob),
161+
);
162+
163+
expect(convertBlobToPng).toHaveBeenCalledWith(pngBlob, 400);
164+
});
165+
166+
it('passes previewWidth to convertSvgToPng so it can resize during transcoding', async () => {
167+
vi.mocked(convertSvgToPng).mockResolvedValue({
168+
png: SVG_CONVERTED_URL,
169+
width: 400,
170+
height: 200,
171+
});
172+
173+
const svgBlob = new Blob(['<svg/>'], { type: 'image/svg+xml' });
174+
await blockMappingImagePDF(
175+
makeBlock({ previewWidth: 400 }),
176+
makeExporter(svgBlob),
177+
);
178+
179+
expect(convertSvgToPng).toHaveBeenCalledWith(expect.any(String), 400);
180+
});
181+
182+
it('uses natural image dimensions for the rendered style when no previewWidth is set', async () => {
183+
// naturalWidth=300, naturalHeight=150 → finalWidth=300, finalHeight=150.
184+
// Rendered: width = 300*PIXELS_PER_POINT(0.75) = 225, height = 150*PIXELS_PER_POINT(0.75) = 112.5.
185+
vi.mocked(convertBlobToPng).mockResolvedValue({
186+
png: CANVAS_PNG_URL,
187+
width: 300,
188+
height: 150,
189+
});
190+
191+
const result = await blockMappingImagePDF(
192+
makeBlock(),
193+
makeExporter(new Blob(['fake-png'], { type: 'image/png' })),
194+
);
195+
196+
const imageEl = findInTree(result as React.ReactNode, 'pdfImage');
197+
expect(imageEl).toBeDefined();
198+
expect(imageEl!.props.style.width).toBe(225);
199+
expect(imageEl!.props.style.height).toBe(112.5);
200+
});
201+
202+
it('scales rendered style to previewWidth when it is within MAX_WIDTH', async () => {
203+
// previewWidth=400 (< MAX_WIDTH=600), natural size 300×150.
204+
// finalWidth=400, finalHeight=(400/300)*150≈200.
205+
// Rendered: width = 400*PIXELS_PER_POINT(0.75) = 300, height ≈ 200*PIXELS_PER_POINT(0.75) = 150.
206+
vi.mocked(convertBlobToPng).mockResolvedValue({
207+
png: CANVAS_PNG_URL,
208+
width: 300,
209+
height: 150,
210+
});
211+
212+
const result = await blockMappingImagePDF(
213+
makeBlock({ previewWidth: 400 }),
214+
makeExporter(new Blob(['fake-png'], { type: 'image/png' })),
215+
);
216+
217+
const imageEl = findInTree(result as React.ReactNode, 'pdfImage');
218+
expect(imageEl).toBeDefined();
219+
expect(imageEl!.props.style.width).toBe(300);
220+
expect(imageEl!.props.style.height).toBe(150);
221+
});
222+
223+
it('returns an empty View when convertBlobToPng returns undefined', async () => {
224+
vi.mocked(convertBlobToPng).mockResolvedValue(undefined);
225+
226+
const result = await blockMappingImagePDF(
227+
makeBlock(),
228+
makeExporter(new Blob(['fake-png'], { type: 'image/png' })),
229+
);
230+
231+
const element = result as React.ReactElement;
232+
expect(element.type).toBe('pdfView');
233+
expect(findInTree(element, 'pdfImage')).toBeUndefined();
234+
});
235+
236+
it('returns an empty View when convertBlobToPng throws', async () => {
237+
vi.mocked(convertBlobToPng).mockRejectedValue(new Error('canvas error'));
238+
239+
const result = await blockMappingImagePDF(
240+
makeBlock(),
241+
makeExporter(new Blob(['fake-png'], { type: 'image/png' })),
242+
);
243+
244+
const element = result as React.ReactElement;
245+
expect(element.type).toBe('pdfView');
246+
expect(findInTree(element, 'pdfImage')).toBeUndefined();
247+
});
248+
249+
it('returns an empty View when convertSvgToPng throws', async () => {
250+
vi.mocked(convertSvgToPng).mockRejectedValue(new Error('canvas error'));
251+
252+
const result = await blockMappingImagePDF(
253+
makeBlock(),
254+
makeExporter(new Blob(['<svg/>'], { type: 'image/svg+xml' })),
255+
);
256+
257+
const element = result as React.ReactElement;
258+
expect(element.type).toBe('pdfView');
259+
expect(findInTree(element, 'pdfImage')).toBeUndefined();
260+
});
261+
262+
it('renders a caption when caption prop is set', async () => {
263+
vi.mocked(convertBlobToPng).mockResolvedValue({
264+
png: CANVAS_PNG_URL,
265+
width: 300,
266+
height: 150,
267+
});
268+
269+
const pngBlob = new Blob(['fake-png'], { type: 'image/png' });
270+
const result = await blockMappingImagePDF(
271+
makeBlock({ caption: 'A test caption' }),
272+
makeExporter(pngBlob),
273+
);
274+
275+
const textEl = findInTree(result as React.ReactNode, 'pdfText');
276+
expect(textEl).toBeDefined();
277+
expect(textEl!.props.children).toBe('A test caption');
278+
});
279+
});

0 commit comments

Comments
 (0)