mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-12 20:57:56 +02:00
🐛(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:
@@ -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();
|
||||
});
|
||||
});
|
||||
+32
-31
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user