diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index f4781c0ce6..f915519e07 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,26 @@ const renderElementToSvg = ( const fileData = isInitializedImageElement(element) && files[element.fileId]; if (fileData) { - const symbolId = `image-${fileData.id}`; + // TODO set to `false` before merging + 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 +440,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 { @@ -476,12 +484,46 @@ const renderElementToSvg = ( } const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + + 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); + } + + if (element.crop) { + use.setAttribute("width", `100%`); + use.setAttribute("height", `100%`); + + 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} ${cx + normalizedCropX} ${cy + normalizedCropY})`, ); if (element.roundness) { diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 67ab3e3abc..8268e8f030 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 = {