fix: image-mirroring in export preview and in exported svg (#5700)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Ryan Di 2022-09-18 05:02:13 +08:00 committed by GitHub
parent 9929a2be6f
commit 3a776f8795
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 163 additions and 9 deletions

View File

@ -308,6 +308,9 @@ export const newLinearElement = (
export const newImageElement = ( export const newImageElement = (
opts: { opts: {
type: ExcalidrawImageElement["type"]; type: ExcalidrawImageElement["type"];
status?: ExcalidrawImageElement["status"];
fileId?: ExcalidrawImageElement["fileId"];
scale?: ExcalidrawImageElement["scale"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => { ): NonDeleted<ExcalidrawImageElement> => {
return { return {
@ -315,9 +318,9 @@ export const newImageElement = (
// in the future we'll support changing stroke color for some SVG elements, // in the future we'll support changing stroke color for some SVG elements,
// and `transparent` will likely mean "use original colors of the image" // and `transparent` will likely mean "use original colors of the image"
strokeColor: "transparent", strokeColor: "transparent",
status: "pending", status: opts.status ?? "pending",
fileId: null, fileId: opts.fileId ?? null,
scale: [1, 1], scale: opts.scale ?? [1, 1],
}; };
}; };

View File

@ -790,6 +790,9 @@ export const renderElement = (
context.save(); context.save();
context.translate(cx, cy); context.translate(cx, cy);
context.rotate(element.angle); context.rotate(element.angle);
if (element.type === "image") {
context.scale(element.scale[0], element.scale[1]);
}
context.translate(-shiftX, -shiftY); context.translate(-shiftX, -shiftY);
if (shouldResetImageFilter(element, renderConfig)) { if (shouldResetImageFilter(element, renderConfig)) {
@ -950,6 +953,8 @@ export const renderElementToSvg = (
break; break;
} }
case "image": { case "image": {
const width = Math.round(element.width);
const height = Math.round(element.height);
const fileData = const fileData =
isInitializedImageElement(element) && files[element.fileId]; isInitializedImageElement(element) && files[element.fileId];
if (fileData) { if (fileData) {
@ -978,17 +983,34 @@ export const renderElementToSvg = (
use.setAttribute("filter", IMAGE_INVERT_FILTER); use.setAttribute("filter", IMAGE_INVERT_FILTER);
} }
use.setAttribute("width", `${Math.round(element.width)}`); use.setAttribute("width", `${width}`);
use.setAttribute("height", `${Math.round(element.height)}`); use.setAttribute("height", `${height}`);
use.setAttribute( // We first apply `scale` transforms (horizontal/vertical mirroring)
// on the <use> element, then apply translation and rotation
// on the <g> element which wraps the <use>.
// 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", "transform",
`translate(${offsetX || 0} ${ `translate(${offsetX || 0} ${
offsetY || 0 offsetY || 0
}) rotate(${degree} ${cx} ${cy})`, }) rotate(${degree} ${cx} ${cy})`,
); );
root.appendChild(use); root.appendChild(g);
} }
break; break;
} }

File diff suppressed because one or more lines are too long

View File

@ -7,6 +7,10 @@ import {
decodeSvgMetadata, decodeSvgMetadata,
} from "../data/image"; } from "../data/image";
import { serializeAsJSON } from "../data/json"; 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; 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 <image> element (deduped)
expect(svgText.match(/<image/g)?.length).toBe(1);
// expect 4 <use> elements (one for each excalidraw image element)
expect(svgText.match(/<use/g)?.length).toBe(4);
// in case of regressions, save the SVG to a file and visually compare to:
// src/tests/fixtures/svg-image-exporting-reference.svg
expect(svgText).toMatchSnapshot(`svg export output`);
});
}); });

BIN
src/tests/fixtures/deer.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -4,6 +4,8 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
FileId,
} from "../../element/types"; } from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element"; import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN } from "../../constants"; import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
@ -13,7 +15,7 @@ import fs from "fs";
import util from "util"; import util from "util";
import path from "path"; import path from "path";
import { getMimeType } from "../../data/blob"; import { getMimeType } from "../../data/blob";
import { newFreeDrawElement } from "../../element/newElement"; import { newFreeDrawElement, newImageElement } from "../../element/newElement";
import { Point } from "../../types"; import { Point } from "../../types";
import { getSelectedElements } from "../../scene/selection"; import { getSelectedElements } from "../../scene/selection";
@ -77,6 +79,7 @@ export class API {
y?: number; y?: number;
height?: number; height?: number;
width?: number; width?: number;
angle?: number;
id?: string; id?: string;
isDeleted?: boolean; isDeleted?: boolean;
groupIds?: string[]; groupIds?: string[];
@ -103,12 +106,17 @@ export class API {
: never; : never;
points?: T extends "arrow" | "line" ? readonly Point[] : never; points?: T extends "arrow" | "line" ? readonly Point[] : never;
locked?: boolean; 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" }): T extends "arrow" | "line"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
: T extends "freedraw" : T extends "freedraw"
? ExcalidrawFreeDrawElement ? ExcalidrawFreeDrawElement
: T extends "text" : T extends "text"
? ExcalidrawTextElement ? ExcalidrawTextElement
: T extends "image"
? ExcalidrawImageElement
: ExcalidrawGenericElement => { : ExcalidrawGenericElement => {
let element: Mutable<ExcalidrawElement> = null!; let element: Mutable<ExcalidrawElement> = null!;
@ -117,6 +125,7 @@ export class API {
const base = { const base = {
x, x,
y, y,
angle: rest.angle ?? 0,
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor, strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
backgroundColor: backgroundColor:
rest.backgroundColor ?? appState.currentItemBackgroundColor, rest.backgroundColor ?? appState.currentItemBackgroundColor,
@ -167,12 +176,23 @@ export class API {
...base, ...base,
width, width,
height, height,
type: type as "arrow" | "line", type,
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
points: rest.points ?? [], points: rest.points ?? [],
}); });
break; 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) { if (id) {
element.id = id; element.id = id;