aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/tests/scene/export.test.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/tests/scene/export.test.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/tests/scene/export.test.ts')
-rw-r--r--packages/excalidraw/tests/scene/export.test.ts521
1 files changed, 521 insertions, 0 deletions
diff --git a/packages/excalidraw/tests/scene/export.test.ts b/packages/excalidraw/tests/scene/export.test.ts
new file mode 100644
index 0000000..6fd8a8b
--- /dev/null
+++ b/packages/excalidraw/tests/scene/export.test.ts
@@ -0,0 +1,521 @@
+import type {
+ ExcalidrawTextElement,
+ FractionalIndex,
+ NonDeletedExcalidrawElement,
+} from "../../element/types";
+import * as exportUtils from "../../scene/export";
+import {
+ diamondFixture,
+ ellipseFixture,
+ rectangleWithLinkFixture,
+ textFixture,
+} from "../fixtures/elementFixture";
+import { API } from "../helpers/api";
+import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
+import { FONT_FAMILY, FRAME_STYLE } from "../../constants";
+import { prepareElementsForExport } from "../../data";
+
+describe("exportToSvg", () => {
+ const ELEMENT_HEIGHT = 100;
+ const ELEMENT_WIDTH = 100;
+ const ELEMENTS = [
+ {
+ ...diamondFixture,
+ height: ELEMENT_HEIGHT,
+ width: ELEMENT_WIDTH,
+ index: "a0",
+ },
+ {
+ ...ellipseFixture,
+ height: ELEMENT_HEIGHT,
+ width: ELEMENT_WIDTH,
+ index: "a1",
+ },
+ {
+ ...textFixture,
+ height: ELEMENT_HEIGHT,
+ width: ELEMENT_WIDTH,
+ index: "a2",
+ },
+ {
+ ...textFixture,
+ fontFamily: FONT_FAMILY.Nunito, // test embedding external font
+ height: ELEMENT_HEIGHT,
+ width: ELEMENT_WIDTH,
+ index: "a3",
+ },
+ ] as NonDeletedExcalidrawElement[];
+
+ const DEFAULT_OPTIONS = {
+ exportBackground: false,
+ viewBackgroundColor: "#ffffff",
+ files: {},
+ };
+
+ it("with default arguments", async () => {
+ const svgElement = await exportUtils.exportToSvg(
+ ELEMENTS,
+ DEFAULT_OPTIONS,
+ null,
+ );
+
+ expect(svgElement).toMatchSnapshot();
+ });
+
+ it("with a CJK font", async () => {
+ const svgElement = await exportUtils.exportToSvg(
+ [
+ ...ELEMENTS,
+ {
+ ...textFixture,
+ height: ELEMENT_HEIGHT,
+ width: ELEMENT_WIDTH,
+ text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
+ originalText:
+ "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
+ index: "a4" as FractionalIndex,
+ } as ExcalidrawTextElement,
+ ],
+ DEFAULT_OPTIONS,
+ null,
+ );
+
+ expect(svgElement).toMatchSnapshot();
+ // extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers)
+ }, 30_000);
+
+ it("with background color", async () => {
+ const BACKGROUND_COLOR = "#abcdef";
+
+ const svgElement = await exportUtils.exportToSvg(
+ ELEMENTS,
+ {
+ ...DEFAULT_OPTIONS,
+ exportBackground: true,
+ viewBackgroundColor: BACKGROUND_COLOR,
+ },
+ null,
+ );
+
+ expect(svgElement.querySelector("rect")).toHaveAttribute(
+ "fill",
+ BACKGROUND_COLOR,
+ );
+ });
+
+ it("with dark mode", async () => {
+ const svgElement = await exportUtils.exportToSvg(
+ ELEMENTS,
+ {
+ ...DEFAULT_OPTIONS,
+ exportWithDarkMode: true,
+ },
+ null,
+ );
+
+ expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
+ `"invert(93%) hue-rotate(180deg)"`,
+ );
+ });
+
+ it("with exportPadding", async () => {
+ const svgElement = await exportUtils.exportToSvg(
+ ELEMENTS,
+ {
+ ...DEFAULT_OPTIONS,
+ exportPadding: 0,
+ },
+ null,
+ );
+
+ expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
+ expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
+ expect(svgElement).toHaveAttribute(
+ "viewBox",
+ `0 0 ${ELEMENT_WIDTH} ${ELEMENT_HEIGHT}`,
+ );
+ });
+
+ it("with scale", async () => {
+ const SCALE = 2;
+
+ const svgElement = await exportUtils.exportToSvg(
+ ELEMENTS,
+ {
+ ...DEFAULT_OPTIONS,
+ exportPadding: 0,
+ exportScale: SCALE,
+ },
+ null,
+ );
+
+ expect(svgElement).toHaveAttribute(
+ "height",
+ (ELEMENT_HEIGHT * SCALE).toString(),
+ );
+ expect(svgElement).toHaveAttribute(
+ "width",
+ (ELEMENT_WIDTH * SCALE).toString(),
+ );
+ });
+
+ it("with exportEmbedScene", async () => {
+ const svgElement = await exportUtils.exportToSvg(
+ ELEMENTS,
+ {
+ ...DEFAULT_OPTIONS,
+ exportEmbedScene: true,
+ },
+ null,
+ );
+ expect(svgElement.innerHTML).toMatchSnapshot();
+ });
+
+ it("with elements that have a link", async () => {
+ const svgElement = await exportUtils.exportToSvg(
+ [rectangleWithLinkFixture],
+ DEFAULT_OPTIONS,
+ null,
+ );
+ expect(svgElement.innerHTML).toMatchSnapshot();
+ });
+});
+
+describe("exporting frames", () => {
+ const getFrameNameHeight = (exportType: "canvas" | "svg") => {
+ const height =
+ FRAME_STYLE.nameFontSize * FRAME_STYLE.nameLineHeight +
+ FRAME_STYLE.nameOffsetY;
+ // canvas truncates dimensions to integers
+ if (exportType === "canvas") {
+ return Math.trunc(height);
+ }
+ return height;
+ };
+
+ // a few tests with exportToCanvas (where we can't inspect elements)
+ // ---------------------------------------------------------------------------
+
+ describe("exportToCanvas", () => {
+ it("exporting canvas with a single frame shouldn't crop if not exporting frame directly", async () => {
+ const elements = [
+ API.createElement({
+ type: "frame",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 0,
+ }),
+ API.createElement({
+ type: "rectangle",
+ width: 100,
+ height: 100,
+ x: 100,
+ y: 0,
+ }),
+ ];
+
+ const canvas = await exportToCanvas({
+ elements,
+ files: null,
+ exportPadding: 0,
+ });
+
+ expect(canvas.width).toEqual(200);
+ expect(canvas.height).toEqual(100 + getFrameNameHeight("canvas"));
+ });
+
+ it("exporting canvas with a single frame should crop when exporting frame directly", async () => {
+ const frame = API.createElement({
+ type: "frame",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 0,
+ });
+ const elements = [
+ frame,
+ API.createElement({
+ type: "rectangle",
+ width: 100,
+ height: 100,
+ x: 100,
+ y: 0,
+ }),
+ ];
+
+ const canvas = await exportToCanvas({
+ elements,
+ files: null,
+ exportPadding: 0,
+ exportingFrame: frame,
+ });
+
+ expect(canvas.width).toEqual(frame.width);
+ expect(canvas.height).toEqual(frame.height);
+ });
+ });
+
+ // exportToSvg (so we can test for element existence)
+ // ---------------------------------------------------------------------------
+ describe("exportToSvg", () => {
+ it("exporting frame should include overlapping elements, but crop to frame", async () => {
+ const frame = API.createElement({
+ type: "frame",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 0,
+ });
+ const frameChild = API.createElement({
+ type: "rectangle",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 50,
+ frameId: frame.id,
+ });
+ const rectOverlapping = API.createElement({
+ type: "rectangle",
+ width: 100,
+ height: 100,
+ x: 50,
+ y: 0,
+ });
+
+ const svg = await exportToSvg({
+ elements: [rectOverlapping, frame, frameChild],
+ files: null,
+ exportPadding: 0,
+ exportingFrame: frame,
+ });
+
+ // frame itself isn't exported
+ expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
+ // frame child is exported
+ expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
+ // overlapping element is exported
+ expect(
+ svg.querySelector(`[data-id="${rectOverlapping.id}"]`),
+ ).not.toBeNull();
+
+ expect(svg.getAttribute("width")).toBe(frame.width.toString());
+ expect(svg.getAttribute("height")).toBe(frame.height.toString());
+ });
+
+ it("should filter non-overlapping elements when exporting a frame", async () => {
+ const frame = API.createElement({
+ type: "frame",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 0,
+ });
+ const frameChild = API.createElement({
+ type: "rectangle",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 50,
+ frameId: frame.id,
+ });
+ const elementOutside = API.createElement({
+ type: "rectangle",
+ width: 100,
+ height: 100,
+ x: 200,
+ y: 0,
+ });
+
+ const svg = await exportToSvg({
+ elements: [frameChild, frame, elementOutside],
+ files: null,
+ exportPadding: 0,
+ exportingFrame: frame,
+ });
+
+ // frame itself isn't exported
+ expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
+ // frame child is exported
+ expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
+ // non-overlapping element is not exported
+ expect(svg.querySelector(`[data-id="${elementOutside.id}"]`)).toBeNull();
+
+ expect(svg.getAttribute("width")).toBe(frame.width.toString());
+ expect(svg.getAttribute("height")).toBe(frame.height.toString());
+ });
+
+ it("should export multiple frames when selected, excluding overlapping elements", async () => {
+ const frame1 = API.createElement({
+ type: "frame",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 0,
+ });
+ const frame2 = API.createElement({
+ type: "frame",
+ width: 100,
+ height: 100,
+ x: 200,
+ y: 0,
+ });
+
+ const frame1Child = API.createElement({
+ type: "rectangle",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 50,
+ frameId: frame1.id,
+ });
+ const frame2Child = API.createElement({
+ type: "rectangle",
+ width: 100,
+ height: 100,
+ x: 200,
+ y: 0,
+ frameId: frame2.id,
+ });
+ const frame2Overlapping = API.createElement({
+ type: "rectangle",
+ width: 100,
+ height: 100,
+ x: 350,
+ y: 0,
+ });
+
+ // low-level exportToSvg api expects elements to be pre-filtered, so let's
+ // use the filter we use in the editor
+ const { exportedElements, exportingFrame } = prepareElementsForExport(
+ [frame1Child, frame1, frame2Child, frame2, frame2Overlapping],
+ {
+ selectedElementIds: { [frame1.id]: true, [frame2.id]: true },
+ },
+ true,
+ );
+
+ const svg = await exportToSvg({
+ elements: exportedElements,
+ files: null,
+ exportPadding: 0,
+ exportingFrame,
+ });
+
+ // frames themselves should be exported when multiple frames selected
+ expect(svg.querySelector(`[data-id="${frame1.id}"]`)).not.toBeNull();
+ expect(svg.querySelector(`[data-id="${frame2.id}"]`)).not.toBeNull();
+ // children should be epxorted
+ expect(svg.querySelector(`[data-id="${frame1Child.id}"]`)).not.toBeNull();
+ expect(svg.querySelector(`[data-id="${frame2Child.id}"]`)).not.toBeNull();
+ // overlapping elements or non-overlapping elements should not be exported
+ expect(
+ svg.querySelector(`[data-id="${frame2Overlapping.id}"]`),
+ ).toBeNull();
+
+ expect(svg.getAttribute("width")).toBe(
+ (frame2.x + frame2.width).toString(),
+ );
+ expect(svg.getAttribute("height")).toBe(
+ (frame2.y + frame2.height + getFrameNameHeight("svg")).toString(),
+ );
+ });
+
+ it("should render frame alone when not selected", async () => {
+ const frame = API.createElement({
+ type: "frame",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 0,
+ });
+
+ // low-level exportToSvg api expects elements to be pre-filtered, so let's
+ // use the filter we use in the editor
+ const { exportedElements, exportingFrame } = prepareElementsForExport(
+ [frame],
+ {
+ selectedElementIds: {},
+ },
+ false,
+ );
+
+ const svg = await exportToSvg({
+ elements: exportedElements,
+ files: null,
+ exportPadding: 0,
+ exportingFrame,
+ });
+
+ // frame itself isn't exported
+ expect(svg.querySelector(`[data-id="${frame.id}"]`)).not.toBeNull();
+
+ expect(svg.getAttribute("width")).toBe(frame.width.toString());
+ expect(svg.getAttribute("height")).toBe(
+ (frame.height + getFrameNameHeight("svg")).toString(),
+ );
+ });
+
+ it("should not export frame-overlapping elements belonging to different frame", async () => {
+ const frame1 = API.createElement({
+ type: "frame",
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 0,
+ });
+ const frame2 = API.createElement({
+ type: "frame",
+ width: 100,
+ height: 100,
+ x: 200,
+ y: 0,
+ });
+
+ const frame1Child = API.createElement({
+ type: "rectangle",
+ width: 150,
+ height: 100,
+ x: 0,
+ y: 50,
+ frameId: frame1.id,
+ });
+ const frame2Child = API.createElement({
+ type: "rectangle",
+ width: 150,
+ height: 100,
+ x: 50,
+ y: 0,
+ frameId: frame2.id,
+ });
+
+ // low-level exportToSvg api expects elements to be pre-filtered, so let's
+ // use the filter we use in the editor
+ const { exportedElements, exportingFrame } = prepareElementsForExport(
+ [frame1Child, frame1, frame2Child, frame2],
+ {
+ selectedElementIds: { [frame1.id]: true },
+ },
+ true,
+ );
+
+ const svg = await exportToSvg({
+ elements: exportedElements,
+ files: null,
+ exportPadding: 0,
+ exportingFrame,
+ });
+
+ // frame shouldn't be exported
+ expect(svg.querySelector(`[data-id="${frame1.id}"]`)).toBeNull();
+ // frame1 child should be epxorted
+ expect(svg.querySelector(`[data-id="${frame1Child.id}"]`)).not.toBeNull();
+ // frame2 child should not be exported even if it physically overlaps with
+ // frame1
+ expect(svg.querySelector(`[data-id="${frame2Child.id}"]`)).toBeNull();
+
+ expect(svg.getAttribute("width")).toBe(frame1.width.toString());
+ expect(svg.getAttribute("height")).toBe(frame1.height.toString());
+ });
+ });
+});