From 2e64298ff49bc55c62465a5b60f51efc4999526e Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 17 Nov 2025 16:29:13 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(pdf)=20preserve=20image=20aspect=20ra?= =?UTF-8?q?tio=20in=20PDF=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit images were distorted in PDF exports; height is now computed to fix that Signed-off-by: Cyril --- CHANGELOG.md | 1 + .../doc-export/blocks-mapping/imageDocx.tsx | 13 ++---- .../doc-export/blocks-mapping/imageODT.tsx | 13 ++---- .../doc-export/blocks-mapping/imagePDF.tsx | 42 +++++++++++++++++-- .../src/features/docs/doc-export/utils.ts | 15 +++++-- 5 files changed, 58 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b22ba50734..8ab285789d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx index 868ba0cf5d..3eb26bb251 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx @@ -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); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageODT.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageODT.tsx index 22f1197216..5e4676fb8a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageODT.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageODT.tsx @@ -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); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx index cad4fd69eb..1a3513cbfd 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx @@ -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 ; @@ -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 ; + } + + 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 ( @@ -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, ) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts index 01c1739e68..505efbb2a2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts @@ -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} 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(