Browse Source

fix: image cropping svg + compat mode (#8710)

Co-authored-by: Ryan Di <[email protected]>
David Luzar 10 months ago
parent
commit
f9815b8b4f

+ 66 - 22
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) {

+ 2 - 0
packages/excalidraw/scene/export.ts

@@ -284,6 +284,7 @@ export const exportToSvg = async (
     renderEmbeddables?: boolean;
     exportingFrame?: ExcalidrawFrameLikeElement | null;
     skipInliningFonts?: true;
+    reuseImages?: boolean;
   },
 ): Promise<SVGSVGElement> => {
   const frameRendering = getFrameRenderingConfig(
@@ -425,6 +426,7 @@ export const exportToSvg = async (
               .map((element) => [element.id, true]),
           )
         : new Map(),
+      reuseImages: opts?.reuseImages ?? true,
     },
   );
 

+ 7 - 0
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 = {

+ 1 - 1
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap

@@ -10,5 +10,5 @@ exports[`export > exporting svg containing transformed images > svg export outpu
     </style>
     
   </defs>
-  <clipPath id="image-clipPath-id1" data-id="id1"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(30.710678118654755 30.710678118654755) rotate(315 50 50)" clip-path="url(#image-clipPath-id1)" data-id="id1"><use href="#image-file_A" width="100" height="100" opacity="1"></use></g><clipPath id="image-clipPath-id2" data-id="id2"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(130.71067811865476 30.710678118654755) rotate(45 25 25)" clip-path="url(#image-clipPath-id2)" data-id="id2"><use href="#image-file_A" width="50" height="50" opacity="1" transform="scale(-1, 1) translate(-50 0)"></use></g><clipPath id="image-clipPath-id3" data-id="id3"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(30.710678118654755 130.71067811865476) rotate(45 50 50)" clip-path="url(#image-clipPath-id3)" data-id="id3"><use href="#image-file_A" width="100" height="100" opacity="1" transform="scale(1, -1) translate(0 -100)"></use></g><clipPath id="image-clipPath-id4" data-id="id4"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(130.71067811865476 130.71067811865476) rotate(315 25 25)" clip-path="url(#image-clipPath-id4)" data-id="id4"><use href="#image-file_A" width="50" height="50" opacity="1" transform="scale(-1, -1) translate(-50 -50)"></use></g></svg>"
+  <clipPath id="image-clipPath-id1" data-id="id1"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(30.710678118654755 30.710678118654755) rotate(315 50 50)" clip-path="url(#image-clipPath-id1)" data-id="id1"><use href="#image-file_A" width="100" height="100" opacity="1"></use></g><clipPath id="image-clipPath-id2" data-id="id2"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(130.71067811865476 30.710678118654755) rotate(45 25 25)" clip-path="url(#image-clipPath-id2)" data-id="id2"><use href="#image-file_A" width="50" height="50" opacity="1" transform="translate(25 25) scale(-1 1) translate(-25 -25)"></use></g><clipPath id="image-clipPath-id3" data-id="id3"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(30.710678118654755 130.71067811865476) rotate(45 50 50)" clip-path="url(#image-clipPath-id3)" data-id="id3"><use href="#image-file_A" width="100" height="100" opacity="1" transform="translate(50 50) scale(1 -1) translate(-50 -50)"></use></g><clipPath id="image-clipPath-id4" data-id="id4"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(130.71067811865476 130.71067811865476) rotate(315 25 25)" clip-path="url(#image-clipPath-id4)" data-id="id4"><use href="#image-file_A" width="50" height="50" opacity="1" transform="translate(25 25) scale(-1 -1) translate(-25 -25)"></use></g></svg>"
 `;

+ 3 - 0
packages/utils/export.ts

@@ -167,10 +167,12 @@ export const exportToSvg = async ({
   renderEmbeddables,
   exportingFrame,
   skipInliningFonts,
+  reuseImages,
 }: Omit<ExportOpts, "getDimensions"> & {
   exportPadding?: number;
   renderEmbeddables?: boolean;
   skipInliningFonts?: true;
+  reuseImages?: boolean;
 }): Promise<SVGSVGElement> => {
   const { elements: restoredElements, appState: restoredAppState } = restore(
     { elements, appState },
@@ -187,6 +189,7 @@ export const exportToSvg = async ({
     exportingFrame,
     renderEmbeddables,
     skipInliningFonts,
+    reuseImages,
   });
 };