(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 pdf embed to use full width #1526
- 🐛(frontend) fix fallback translations with Trans #1620 - 🐛(frontend) fix fallback translations with Trans #1620
- 🐛(pdf) fix table cell alignment issue in exported documents #1582 - 🐛(pdf) fix table cell alignment issue in exported documents #1582
- 🐛(pdf) preserve image aspect ratio in PDF export #1622
### Security ### Security

View File

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

View File

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

View File

@@ -6,12 +6,14 @@ import { convertSvgToPng } from '../utils';
const PIXELS_PER_POINT = 0.75; const PIXELS_PER_POINT = 0.75;
const FONT_SIZE = 16; const FONT_SIZE = 16;
const MAX_WIDTH = 600;
export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['image'] = export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['image'] =
async (block, exporter) => { async (block, exporter) => {
const blob = await exporter.resolveFile(block.props.url); const blob = await exporter.resolveFile(block.props.url);
let pngConverted: string | undefined; 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')) { if (!blob.type.includes('image')) {
return <View wrap={false} />; return <View wrap={false} />;
@@ -20,16 +22,33 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
if (blob.type.includes('svg')) { if (blob.type.includes('svg')) {
const svgText = await blob.text(); const svgText = await blob.text();
const FALLBACK_SIZE = 536; const FALLBACK_SIZE = 536;
width = width || blob.size || FALLBACK_SIZE; previewWidth = previewWidth || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, width);
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 ( return (
<View wrap={false}> <View wrap={false}>
<Image <Image
src={pngConverted || blob} src={pngConverted || blob}
style={{ style={{
width: width ? width * PIXELS_PER_POINT : undefined, width: finalWidth * PIXELS_PER_POINT,
height: finalHeight * PIXELS_PER_POINT,
maxWidth: '100%', 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( function caption(
props: Partial<DefaultProps & { caption: string; previewWidth: number }>, 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 * 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. * to preserve the aspect ratio, and renders the SVG onto the canvas using Canvg.
* *
* @param {string} svgText - The raw SVG markup to convert. * @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). * @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. * @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 // Create a canvas and render the SVG onto it
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { const ctx = canvas.getContext('2d', {
@@ -64,7 +67,11 @@ export async function convertSvgToPng(svgText: string, width: number) {
svg.resize(width, height, true); svg.resize(width, height, true);
await svg.render(); await svg.render();
return canvas.toDataURL('image/png'); return {
png: canvas.toDataURL('image/png'),
width,
height: height || width,
};
} }
export function docxBlockPropsToStyles( export function docxBlockPropsToStyles(