diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 2d6a12af..ad5161b7 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -308,6 +308,9 @@ export const newLinearElement = ( export const newImageElement = ( opts: { type: ExcalidrawImageElement["type"]; + status?: ExcalidrawImageElement["status"]; + fileId?: ExcalidrawImageElement["fileId"]; + scale?: ExcalidrawImageElement["scale"]; } & ElementConstructorOpts, ): NonDeleted => { return { @@ -315,9 +318,9 @@ export const newImageElement = ( // in the future we'll support changing stroke color for some SVG elements, // and `transparent` will likely mean "use original colors of the image" strokeColor: "transparent", - status: "pending", - fileId: null, - scale: [1, 1], + status: opts.status ?? "pending", + fileId: opts.fileId ?? null, + scale: opts.scale ?? [1, 1], }; }; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index e53dd698..1265aebc 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -790,6 +790,9 @@ export const renderElement = ( context.save(); context.translate(cx, cy); context.rotate(element.angle); + if (element.type === "image") { + context.scale(element.scale[0], element.scale[1]); + } context.translate(-shiftX, -shiftY); if (shouldResetImageFilter(element, renderConfig)) { @@ -950,6 +953,8 @@ export const renderElementToSvg = ( break; } case "image": { + const width = Math.round(element.width); + const height = Math.round(element.height); const fileData = isInitializedImageElement(element) && files[element.fileId]; if (fileData) { @@ -978,17 +983,34 @@ export const renderElementToSvg = ( use.setAttribute("filter", IMAGE_INVERT_FILTER); } - use.setAttribute("width", `${Math.round(element.width)}`); - use.setAttribute("height", `${Math.round(element.height)}`); + use.setAttribute("width", `${width}`); + use.setAttribute("height", `${height}`); - use.setAttribute( + // We first apply `scale` transforms (horizontal/vertical mirroring) + // on the element, then apply translation and rotation + // on the element which wraps the . + // Doing this separately is a quick hack to to work around compositing + // 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})`, + ); + } + + const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + g.appendChild(use); + g.setAttribute( "transform", `translate(${offsetX || 0} ${ offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); - root.appendChild(use); + root.appendChild(g); } break; } diff --git a/src/tests/__snapshots__/export.test.tsx.snap b/src/tests/__snapshots__/export.test.tsx.snap new file mode 100644 index 00000000..857a1e59 --- /dev/null +++ b/src/tests/__snapshots__/export.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`export exporting svg containing transformed images: svg export output 1`] = ` +" + + + + + + " +`; diff --git a/src/tests/export.test.tsx b/src/tests/export.test.tsx index e6bd2182..02e6a13a 100644 --- a/src/tests/export.test.tsx +++ b/src/tests/export.test.tsx @@ -7,6 +7,10 @@ import { decodeSvgMetadata, } from "../data/image"; import { serializeAsJSON } from "../data/json"; +import { exportToSvg } from "../scene/export"; +import { FileId } from "../element/types"; +import { getDataURL } from "../data/blob"; +import { getDefaultAppState } from "../appState"; const { h } = window; @@ -101,4 +105,73 @@ describe("export", () => { ]); }); }); + + it("exporting svg containing transformed images", async () => { + const normalizeAngle = (angle: number) => (angle / 180) * Math.PI; + + const elements = [ + API.createElement({ + type: "image", + fileId: "file_A", + x: 0, + y: 0, + scale: [1, 1], + width: 100, + height: 100, + angle: normalizeAngle(315), + }), + API.createElement({ + type: "image", + fileId: "file_A", + x: 100, + y: 0, + scale: [-1, 1], + width: 50, + height: 50, + angle: normalizeAngle(45), + }), + API.createElement({ + type: "image", + fileId: "file_A", + x: 0, + y: 100, + scale: [1, -1], + width: 100, + height: 100, + angle: normalizeAngle(45), + }), + API.createElement({ + type: "image", + fileId: "file_A", + x: 100, + y: 100, + scale: [-1, -1], + width: 50, + height: 50, + angle: normalizeAngle(315), + }), + ]; + const appState = { ...getDefaultAppState(), exportBackground: false }; + const files = { + file_A: { + id: "file_A" as FileId, + dataURL: await getDataURL(await API.loadFile("./fixtures/deer.png")), + mimeType: "image/png", + created: Date.now(), + }, + } as const; + + const svg = await exportToSvg(elements, appState, files); + + const svgText = svg.outerHTML; + + // expect 1 element (deduped) + expect(svgText.match(/ elements (one for each excalidraw image element) + expect(svgText.match(/ + + + + + + \ No newline at end of file diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index f0afdd2c..14d9a8e2 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -4,6 +4,8 @@ import { ExcalidrawTextElement, ExcalidrawLinearElement, ExcalidrawFreeDrawElement, + ExcalidrawImageElement, + FileId, } from "../../element/types"; import { newElement, newTextElement, newLinearElement } from "../../element"; import { DEFAULT_VERTICAL_ALIGN } from "../../constants"; @@ -13,7 +15,7 @@ import fs from "fs"; import util from "util"; import path from "path"; import { getMimeType } from "../../data/blob"; -import { newFreeDrawElement } from "../../element/newElement"; +import { newFreeDrawElement, newImageElement } from "../../element/newElement"; import { Point } from "../../types"; import { getSelectedElements } from "../../scene/selection"; @@ -77,6 +79,7 @@ export class API { y?: number; height?: number; width?: number; + angle?: number; id?: string; isDeleted?: boolean; groupIds?: string[]; @@ -103,12 +106,17 @@ export class API { : never; points?: T extends "arrow" | "line" ? readonly Point[] : never; locked?: boolean; + fileId?: T extends "image" ? string : never; + scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; + status?: T extends "image" ? ExcalidrawImageElement["status"] : never; }): T extends "arrow" | "line" ? ExcalidrawLinearElement : T extends "freedraw" ? ExcalidrawFreeDrawElement : T extends "text" ? ExcalidrawTextElement + : T extends "image" + ? ExcalidrawImageElement : ExcalidrawGenericElement => { let element: Mutable = null!; @@ -117,6 +125,7 @@ export class API { const base = { x, y, + angle: rest.angle ?? 0, strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor, backgroundColor: rest.backgroundColor ?? appState.currentItemBackgroundColor, @@ -167,12 +176,23 @@ export class API { ...base, width, height, - type: type as "arrow" | "line", + type, startArrowhead: null, endArrowhead: null, points: rest.points ?? [], }); break; + case "image": + element = newImageElement({ + ...base, + width, + height, + type, + fileId: (rest.fileId as string as FileId) ?? null, + status: rest.status || "saved", + scale: rest.scale || [1, 1], + }); + break; } if (id) { element.id = id;