(pdf) preserve image aspect ratio in PDF export

images were distorted in PDF exports; height is now computed to fix that

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-11-17 16:29:13 +01:00
parent 8dad9ea6c4
commit 2e64298ff4
5 changed files with 58 additions and 26 deletions

View File

@@ -23,6 +23,7 @@ and this project adheres to
- 🐛(frontend) fix pdf embed to use full width #1526
- 🐛(frontend) fix fallback translations with Trans #1620
- 🐛(pdf) fix table cell alignment issue in exported documents #1582
- 🐛(pdf) preserve image aspect ratio in PDF export #1622
### Security

View File

@@ -31,15 +31,10 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, previewWidth);
const img = new Image();
img.src = pngConverted;
await new Promise((resolve) => {
img.onload = () => {
dimensions = { width: img.width, height: img.height };
resolve(null);
};
});
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };
} else {
dimensions = await getImageDimensions(blob);
}

View File

@@ -28,15 +28,10 @@ export const blockMappingImageODT: DocsExporterODT['mappings']['blockMapping']['
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, previewWidth);
const img = new Image();
img.src = pngConverted;
await new Promise((resolve) => {
img.onload = () => {
dimensions = { width: img.width, height: img.height };
resolve(null);
};
});
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };
} else {
dimensions = await getImageDimensions(blob);
}

View File

@@ -6,12 +6,14 @@ import { convertSvgToPng } from '../utils';
const PIXELS_PER_POINT = 0.75;
const FONT_SIZE = 16;
const MAX_WIDTH = 600;
export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['image'] =
async (block, exporter) => {
const blob = await exporter.resolveFile(block.props.url);
let pngConverted: string | undefined;
let width = block.props.previewWidth || undefined;
let dimensions: { width: number; height: number } | undefined;
let previewWidth = block.props.previewWidth || undefined;
if (!blob.type.includes('image')) {
return <View wrap={false} />;
@@ -20,16 +22,33 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
if (blob.type.includes('svg')) {
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
width = width || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, width);
previewWidth = previewWidth || FALLBACK_SIZE;
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };
} else {
dimensions = await getImageDimensions(blob);
}
if (!dimensions) {
return <View wrap={false} />;
}
const { width, height } = dimensions;
// Ensure the final width never exceeds MAX_WIDTH to prevent images
// from overflowing the page width in the exported document
const finalWidth = Math.min(previewWidth || width, MAX_WIDTH);
const finalHeight = (finalWidth / width) * height;
return (
<View wrap={false}>
<Image
src={pngConverted || blob}
style={{
width: width ? width * PIXELS_PER_POINT : undefined,
width: finalWidth * PIXELS_PER_POINT,
height: finalHeight * PIXELS_PER_POINT,
maxWidth: '100%',
}}
/>
@@ -38,6 +57,21 @@ 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 }>,
) {

View File

@@ -20,18 +20,21 @@ export function downloadFile(blob: Blob, filename: string) {
}
/**
* Converts an SVG string into a PNG image and returns it as a data URL.
* Converts an SVG string into a PNG image and returns it as a data URL with dimensions.
*
* This function creates a canvas, parses the SVG, calculates the appropriate height
* to preserve the aspect ratio, and renders the SVG onto the canvas using Canvg.
*
* @param {string} svgText - The raw SVG markup to convert.
* @param {number} width - The desired width of the output PNG (height is auto-calculated to preserve aspect ratio).
* @returns {Promise<string>} A Promise that resolves to a PNG image encoded as a base64 data URL.
* @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.
*/
export async function convertSvgToPng(svgText: string, width: number) {
export async function convertSvgToPng(
svgText: string,
width: number,
): Promise<{ png: string; width: number; height: number }> {
// Create a canvas and render the SVG onto it
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', {
@@ -64,7 +67,11 @@ export async function convertSvgToPng(svgText: string, width: number) {
svg.resize(width, height, true);
await svg.render();
return canvas.toDataURL('image/png');
return {
png: canvas.toDataURL('image/png'),
width,
height: height || width,
};
}
export function docxBlockPropsToStyles(