import { exportToCanvas, exportToSvg } from "@excalidraw/utils"; import { applyDarkModeFilter, BOUND_TEXT_PADDING, FONT_FAMILY, FRAME_STYLE, } from "@excalidraw/common"; import { pointFrom } from "@excalidraw/math"; import type { ExcalidrawTextElement, FractionalIndex, NonDeleted, NonDeletedExcalidrawElement, } from "@excalidraw/element/types"; import type { LocalPoint } from "@excalidraw/math"; import { prepareElementsForExport } from "../../data"; import * as exportUtils from "../../scene/export"; import { diamondFixture, ellipseFixture, rectangleWithLinkFixture, textFixture, } from "../fixtures/elementFixture"; import { API } from "../helpers/api"; 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 NonDeleted, ], 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, ); const textElements = svgElement.querySelectorAll("text"); expect(textElements.length).toBeGreaterThan(0); textElements.forEach((textEl) => { // fill color should be inverted in dark mode expect(textEl.getAttribute("fill")).toBe( applyDarkModeFilter(textFixture.strokeColor), ); }); }); 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] as NonDeletedExcalidrawElement[], DEFAULT_OPTIONS, null, ); expect(svgElement.innerHTML).toMatchSnapshot(); }); // #11439: a perfectly horizontal/vertical arrow has a zero-size bounding box. // The bound-text "gap" mask must use userSpaceOnUse units, otherwise its // objectBoundingBox region collapses to zero area and the whole arrow line // disappears from the SVG export (only the label remains). it("keeps a horizontal arrow with a bound label visible (#11439)", async () => { const arrow = API.createElement({ type: "arrow", id: "arrow-11439", width: 200, height: 0, points: [pointFrom(0, 0), pointFrom(200, 0)], boundElements: [{ type: "text", id: "label-11439" }], }); const label = API.createElement({ type: "text", id: "label-11439", text: "label", width: 50, height: 20, containerId: "arrow-11439", }); const svgElement = await exportUtils.exportToSvg( [arrow, label] as NonDeletedExcalidrawElement[], DEFAULT_OPTIONS, null, ); const mask = svgElement.querySelector("mask"); expect(mask).not.toBeNull(); expect(mask?.getAttribute("maskUnits")).toBe("userSpaceOnUse"); // a degenerate (objectBoundingBox) region would be zero-area here expect(Number(mask?.getAttribute("width"))).toBeGreaterThan(0); expect(Number(mask?.getAttribute("height"))).toBeGreaterThan(0); // the masked arrow group still renders its line (not clipped away) expect(svgElement.querySelector("g[mask] path")).not.toBeNull(); }); it("cuts the same padded hole around an arrow label as the canvas", async () => { const arrow = API.createElement({ type: "arrow", id: "arrow-hole", x: 100, y: 100, width: 200, height: 0, points: [pointFrom(0, 0), pointFrom(200, 0)], boundElements: [{ type: "text", id: "label-hole" }], }); const label = API.createElement({ type: "text", id: "label-hole", text: "label", width: 50, height: 20, containerId: "arrow-hole", }); const svgElement = await exportUtils.exportToSvg( [arrow, label] as NonDeletedExcalidrawElement[], { ...DEFAULT_OPTIONS, exportPadding: 0 }, null, ); // the mask's first rect is the visible region, sized to the arrow plus // its offset within the export; the second is the hole cut for the label const [visible, hole] = Array.from( svgElement.querySelectorAll("mask rect"), ); expect(hole).toBeDefined(); const offsetX = Number(visible.getAttribute("width")) - 100 - arrow.width; const offsetY = Number(visible.getAttribute("height")) - 100 - arrow.height; // the label sits centered on the arrow's midpoint; the hole is its box // grown by the padding on every side, in the mask's (arrow-relative) // coordinates const labelLeft = arrow.x + arrow.width / 2 - label.width / 2; const labelTop = arrow.y - label.height / 2; expect(Number(hole.getAttribute("x"))).toBe( offsetX + labelLeft - arrow.x - BOUND_TEXT_PADDING, ); expect(Number(hole.getAttribute("y"))).toBe( offsetY + labelTop - arrow.y - BOUND_TEXT_PADDING, ); expect(Number(hole.getAttribute("width"))).toBe( label.width + BOUND_TEXT_PADDING * 2, ); expect(Number(hole.getAttribute("height"))).toBe( label.height + BOUND_TEXT_PADDING * 2, ); }); }); describe("exportToCanvas", () => { it("does not paint over elements beneath a labeled arrow's label (#11591)", async () => { const rectangle = API.createElement({ type: "rectangle", id: "rect-11591", x: 0, y: -50, width: 200, height: 100, backgroundColor: "#ffc9c9", fillStyle: "solid", }); const arrow = API.createElement({ type: "arrow", id: "arrow-11591", x: -50, y: 0, width: 300, height: 0, points: [pointFrom(0, 0), pointFrom(300, 0)], boundElements: [{ type: "text", id: "label-11591" }], }); const label = API.createElement({ type: "text", id: "label-11591", text: "label", x: 75, y: -10, width: 50, height: 20, containerId: "arrow-11591", }); const canvas = await exportToCanvas({ elements: [rectangle, arrow, label], files: null, appState: { exportBackground: true, viewBackgroundColor: "#ffffff" }, }); const context = canvas.getContext("2d") as any; const events = context.__getEvents(); // the label "hole" must be cut out of the arrow's own strokes via an // even-odd clip — erasing (clearRect) or painting the background color // (fillRect) over the hole rect would destroy elements rendered beneath // the arrow expect( events.some( (event: any) => event.type === "clip" && event.props.fillRule === "evenodd", ), ).toBe(true); const holeWidth = label.width + BOUND_TEXT_PADDING * 2; const holeHeight = label.height + BOUND_TEXT_PADDING * 2; expect( events.filter( (event: any) => (event.type === "fillRect" || event.type === "clearRect") && event.props.width === holeWidth && event.props.height === holeHeight, ), ).toEqual([]); }); }); 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()); }); }); });