diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index 85a92c7365..94410e0e71 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -291,6 +291,14 @@ const renderElementToSvg = ( ); offsetX = offsetX || 0; offsetY = offsetY || 0; + // Pin the mask to user space; the default maskUnits="objectBoundingBox" + // collapses to zero area for axis-aligned arrows (zero-size bbox), + // hiding the whole line from SVG exports (#11439). + maskPath.setAttribute("maskUnits", "userSpaceOnUse"); + maskPath.setAttribute("x", "0"); + maskPath.setAttribute("y", "0"); + maskPath.setAttribute("width", `${element.width + 100 + offsetX}`); + maskPath.setAttribute("height", `${element.height + 100 + offsetY}`); maskRectVisible.setAttribute("x", "0"); maskRectVisible.setAttribute("y", "0"); maskRectVisible.setAttribute("fill", "#fff"); diff --git a/packages/excalidraw/tests/scene/export.test.ts b/packages/excalidraw/tests/scene/export.test.ts index b97f4619e9..2a00e7245a 100644 --- a/packages/excalidraw/tests/scene/export.test.ts +++ b/packages/excalidraw/tests/scene/export.test.ts @@ -6,12 +6,16 @@ import { FRAME_STYLE, } from "@excalidraw/common"; +import { pointFrom } from "@excalidraw/math"; + import type { ExcalidrawTextElement, FractionalIndex, NonDeletedExcalidrawElement, } from "@excalidraw/element/types"; +import type { LocalPoint } from "@excalidraw/math"; + import { prepareElementsForExport } from "../../data"; import * as exportUtils from "../../scene/export"; import { @@ -192,6 +196,45 @@ describe("exportToSvg", () => { ); 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(); + }); }); describe("exporting frames", () => {