From f9815b8b4f612ec1ed4cf2484a61e4ac0befc187 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 28 Oct 2024 10:08:05 +0100 Subject: [PATCH] fix: image cropping svg + compat mode (#8710) Co-authored-by: Ryan Di --- .../excalidraw/renderer/staticSvgScene.ts | 88 ++++++++++++++----- packages/excalidraw/scene/export.ts | 2 + packages/excalidraw/scene/types.ts | 7 ++ .../tests/__snapshots__/export.test.tsx.snap | 2 +- packages/utils/export.ts | 3 + 5 files changed, 79 insertions(+), 23 deletions(-) diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index f4781c0ce6..5570ad8c38 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -7,7 +7,7 @@ import { SVG_NS, } from "../constants"; import { normalizeLink, toValidURL } from "../data/url"; -import { getElementAbsoluteCoords } from "../element"; +import { getElementAbsoluteCoords, hashString } from "../element"; import { createPlaceholderEmbeddableLabel, getEmbedLink, @@ -411,7 +411,25 @@ const renderElementToSvg = ( const fileData = isInitializedImageElement(element) && files[element.fileId]; if (fileData) { - const symbolId = `image-${fileData.id}`; + const { reuseImages = true } = renderConfig; + + let symbolId = `image-${fileData.id}`; + + let uncroppedWidth = element.width; + let uncroppedHeight = element.height; + if (element.crop) { + ({ width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element)); + + symbolId = `image-crop-${fileData.id}-${hashString( + `${uncroppedWidth}x${uncroppedHeight}`, + )}`; + } + + if (!reuseImages) { + symbolId = `image-${element.id}`; + } + let symbol = svgRoot.querySelector(`#${symbolId}`); if (!symbol) { symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); @@ -421,18 +439,7 @@ const renderElementToSvg = ( image.setAttribute("href", fileData.dataURL); image.setAttribute("preserveAspectRatio", "none"); - if (element.crop) { - const { width: uncroppedWidth, height: uncroppedHeight } = - getUncroppedWidthAndHeight(element); - - symbol.setAttribute( - "viewBox", - `${ - element.crop.x / (element.crop.naturalWidth / uncroppedWidth) - } ${ - element.crop.y / (element.crop.naturalHeight / uncroppedHeight) - } ${width} ${height}`, - ); + if (element.crop || !reuseImages) { image.setAttribute("width", `${uncroppedWidth}`); image.setAttribute("height", `${uncroppedHeight}`); } else { @@ -456,8 +463,23 @@ const renderElementToSvg = ( use.setAttribute("filter", IMAGE_INVERT_FILTER); } - use.setAttribute("width", `${width}`); - use.setAttribute("height", `${height}`); + let normalizedCropX = 0; + let normalizedCropY = 0; + + if (element.crop) { + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + normalizedCropX = + element.crop.x / (element.crop.naturalWidth / uncroppedWidth); + normalizedCropY = + element.crop.y / (element.crop.naturalHeight / uncroppedHeight); + } + + const adjustedCenterX = cx + normalizedCropX; + const adjustedCenterY = cy + normalizedCropY; + + use.setAttribute("width", `${width + normalizedCropX}`); + use.setAttribute("height", `${height + normalizedCropY}`); use.setAttribute("opacity", `${opacity}`); // We first apply `scale` transforms (horizontal/vertical mirroring) @@ -467,21 +489,43 @@ const renderElementToSvg = ( // the transformations correctly (the transform-origin was not being // applied correctly). if (element.scale[0] !== 1 || element.scale[1] !== 1) { - const translateX = element.scale[0] !== 1 ? -width : 0; - const translateY = element.scale[1] !== 1 ? -height : 0; use.setAttribute( "transform", - `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, + `translate(${adjustedCenterX} ${adjustedCenterY}) scale(${ + element.scale[0] + } ${ + element.scale[1] + }) translate(${-adjustedCenterX} ${-adjustedCenterY})`, ); } const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + + if (element.crop) { + const mask = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); + mask.setAttribute("id", `mask-image-crop-${element.id}`); + mask.setAttribute("fill", "#fff"); + const maskRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + + maskRect.setAttribute("x", `${normalizedCropX}`); + maskRect.setAttribute("y", `${normalizedCropY}`); + maskRect.setAttribute("width", `${width}`); + maskRect.setAttribute("height", `${height}`); + + mask.appendChild(maskRect); + root.appendChild(mask); + g.setAttribute("mask", `url(#${mask.id})`); + } + g.appendChild(use); g.setAttribute( "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, + `translate(${offsetX - normalizedCropX} ${ + offsetY - normalizedCropY + }) rotate(${degree} ${adjustedCenterX} ${adjustedCenterY})`, ); if (element.roundness) { diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 85f5f2b7c3..a311e4404c 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -284,6 +284,7 @@ export const exportToSvg = async ( renderEmbeddables?: boolean; exportingFrame?: ExcalidrawFrameLikeElement | null; skipInliningFonts?: true; + reuseImages?: boolean; }, ): Promise => { const frameRendering = getFrameRenderingConfig( @@ -425,6 +426,7 @@ export const exportToSvg = async ( .map((element) => [element.id, true]), ) : new Map(), + reuseImages: opts?.reuseImages ?? true, }, ); diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 67ab3e3abc..46ee26b742 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -46,6 +46,13 @@ export type SVGRenderConfig = { frameRendering: AppState["frameRendering"]; canvasBackgroundColor: AppState["viewBackgroundColor"]; embedsValidationStatus: EmbedsValidationStatus; + /** + * whether to attempt to reuse images as much as possible through symbols + * (reduces SVG size, but may be incompoatible with some SVG renderers) + * + * @default true + */ + reuseImages: boolean; }; export type InteractiveCanvasRenderConfig = { diff --git a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap index fd4f902b2f..1ba16e2ee2 100644 --- a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap @@ -10,5 +10,5 @@ exports[`export > exporting svg containing transformed images > svg export outpu - " + " `; diff --git a/packages/utils/export.ts b/packages/utils/export.ts index 9adb6fec38..a82ef66e62 100644 --- a/packages/utils/export.ts +++ b/packages/utils/export.ts @@ -167,10 +167,12 @@ export const exportToSvg = async ({ renderEmbeddables, exportingFrame, skipInliningFonts, + reuseImages, }: Omit & { exportPadding?: number; renderEmbeddables?: boolean; skipInliningFonts?: true; + reuseImages?: boolean; }): Promise => { const { elements: restoredElements, appState: restoredAppState } = restore( { elements, appState }, @@ -187,6 +189,7 @@ export const exportToSvg = async ({ exportingFrame, renderEmbeddables, skipInliningFonts, + reuseImages, }); };