staticSvgScene.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  1. import type { Drawable } from "roughjs/bin/core";
  2. import type { RoughSVG } from "roughjs/bin/svg";
  3. import {
  4. FRAME_STYLE,
  5. MAX_DECIMALS_FOR_SVG_EXPORT,
  6. MIME_TYPES,
  7. SVG_NS,
  8. } from "../constants";
  9. import { normalizeLink, toValidURL } from "../data/url";
  10. import { getElementAbsoluteCoords, hashString } from "../element";
  11. import {
  12. createPlaceholderEmbeddableLabel,
  13. getEmbedLink,
  14. } from "../element/embeddable";
  15. import { LinearElementEditor } from "../element/linearElementEditor";
  16. import {
  17. getBoundTextElement,
  18. getContainerElement,
  19. getLineHeightInPx,
  20. } from "../element/textElement";
  21. import {
  22. isArrowElement,
  23. isIframeLikeElement,
  24. isInitializedImageElement,
  25. isTextElement,
  26. } from "../element/typeChecks";
  27. import type {
  28. ExcalidrawElement,
  29. ExcalidrawTextElementWithContainer,
  30. NonDeletedExcalidrawElement,
  31. } from "../element/types";
  32. import { getContainingFrame } from "../frame";
  33. import { ShapeCache } from "../scene/ShapeCache";
  34. import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
  35. import type { AppState, BinaryFiles } from "../types";
  36. import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
  37. import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
  38. import { getVerticalOffset } from "../fonts";
  39. import { getCornerRadius, isPathALoop } from "../shapes";
  40. import { getUncroppedWidthAndHeight } from "../element/cropElement";
  41. const roughSVGDrawWithPrecision = (
  42. rsvg: RoughSVG,
  43. drawable: Drawable,
  44. precision?: number,
  45. ) => {
  46. if (typeof precision === "undefined") {
  47. return rsvg.draw(drawable);
  48. }
  49. const pshape: Drawable = {
  50. sets: drawable.sets,
  51. shape: drawable.shape,
  52. options: { ...drawable.options, fixedDecimalPlaceDigits: precision },
  53. };
  54. return rsvg.draw(pshape);
  55. };
  56. const maybeWrapNodesInFrameClipPath = (
  57. element: NonDeletedExcalidrawElement,
  58. root: SVGElement,
  59. nodes: SVGElement[],
  60. frameRendering: AppState["frameRendering"],
  61. elementsMap: RenderableElementsMap,
  62. ) => {
  63. if (!frameRendering.enabled || !frameRendering.clip) {
  64. return null;
  65. }
  66. const frame = getContainingFrame(element, elementsMap);
  67. if (frame) {
  68. const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
  69. g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
  70. nodes.forEach((node) => g.appendChild(node));
  71. return g;
  72. }
  73. return null;
  74. };
  75. const renderElementToSvg = (
  76. element: NonDeletedExcalidrawElement,
  77. elementsMap: RenderableElementsMap,
  78. rsvg: RoughSVG,
  79. svgRoot: SVGElement,
  80. files: BinaryFiles,
  81. offsetX: number,
  82. offsetY: number,
  83. renderConfig: SVGRenderConfig,
  84. ) => {
  85. const offset = { x: offsetX, y: offsetY };
  86. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
  87. let cx = (x2 - x1) / 2 - (element.x - x1);
  88. let cy = (y2 - y1) / 2 - (element.y - y1);
  89. if (isTextElement(element)) {
  90. const container = getContainerElement(element, elementsMap);
  91. if (isArrowElement(container)) {
  92. const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap);
  93. const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
  94. container,
  95. element as ExcalidrawTextElementWithContainer,
  96. elementsMap,
  97. );
  98. cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
  99. cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
  100. offsetX = offsetX + boundTextCoords.x - element.x;
  101. offsetY = offsetY + boundTextCoords.y - element.y;
  102. }
  103. }
  104. const degree = (180 * element.angle) / Math.PI;
  105. // element to append node to, most of the time svgRoot
  106. let root = svgRoot;
  107. // if the element has a link, create an anchor tag and make that the new root
  108. if (element.link) {
  109. const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
  110. anchorTag.setAttribute("href", normalizeLink(element.link));
  111. root.appendChild(anchorTag);
  112. root = anchorTag;
  113. }
  114. const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
  115. if (isTestEnv()) {
  116. node.setAttribute("data-id", element.id);
  117. }
  118. root.appendChild(node);
  119. };
  120. const opacity =
  121. ((getContainingFrame(element, elementsMap)?.opacity ?? 100) *
  122. element.opacity) /
  123. 10000;
  124. switch (element.type) {
  125. case "selection": {
  126. // Since this is used only during editing experience, which is canvas based,
  127. // this should not happen
  128. throw new Error("Selection rendering is not supported for SVG");
  129. }
  130. case "rectangle":
  131. case "diamond":
  132. case "ellipse": {
  133. const shape = ShapeCache.generateElementShape(element, null);
  134. const node = roughSVGDrawWithPrecision(
  135. rsvg,
  136. shape,
  137. MAX_DECIMALS_FOR_SVG_EXPORT,
  138. );
  139. if (opacity !== 1) {
  140. node.setAttribute("stroke-opacity", `${opacity}`);
  141. node.setAttribute("fill-opacity", `${opacity}`);
  142. }
  143. node.setAttribute("stroke-linecap", "round");
  144. node.setAttribute(
  145. "transform",
  146. `translate(${offsetX || 0} ${
  147. offsetY || 0
  148. }) rotate(${degree} ${cx} ${cy})`,
  149. );
  150. const g = maybeWrapNodesInFrameClipPath(
  151. element,
  152. root,
  153. [node],
  154. renderConfig.frameRendering,
  155. elementsMap,
  156. );
  157. addToRoot(g || node, element);
  158. break;
  159. }
  160. case "iframe":
  161. case "embeddable": {
  162. // render placeholder rectangle
  163. const shape = ShapeCache.generateElementShape(element, renderConfig);
  164. const node = roughSVGDrawWithPrecision(
  165. rsvg,
  166. shape,
  167. MAX_DECIMALS_FOR_SVG_EXPORT,
  168. );
  169. const opacity = element.opacity / 100;
  170. if (opacity !== 1) {
  171. node.setAttribute("stroke-opacity", `${opacity}`);
  172. node.setAttribute("fill-opacity", `${opacity}`);
  173. }
  174. node.setAttribute("stroke-linecap", "round");
  175. node.setAttribute(
  176. "transform",
  177. `translate(${offsetX || 0} ${
  178. offsetY || 0
  179. }) rotate(${degree} ${cx} ${cy})`,
  180. );
  181. addToRoot(node, element);
  182. const label: ExcalidrawElement =
  183. createPlaceholderEmbeddableLabel(element);
  184. renderElementToSvg(
  185. label,
  186. elementsMap,
  187. rsvg,
  188. root,
  189. files,
  190. label.x + offset.x - element.x,
  191. label.y + offset.y - element.y,
  192. renderConfig,
  193. );
  194. // render embeddable element + iframe
  195. const embeddableNode = roughSVGDrawWithPrecision(
  196. rsvg,
  197. shape,
  198. MAX_DECIMALS_FOR_SVG_EXPORT,
  199. );
  200. embeddableNode.setAttribute("stroke-linecap", "round");
  201. embeddableNode.setAttribute(
  202. "transform",
  203. `translate(${offsetX || 0} ${
  204. offsetY || 0
  205. }) rotate(${degree} ${cx} ${cy})`,
  206. );
  207. while (embeddableNode.firstChild) {
  208. embeddableNode.removeChild(embeddableNode.firstChild);
  209. }
  210. const radius = getCornerRadius(
  211. Math.min(element.width, element.height),
  212. element,
  213. );
  214. const embedLink = getEmbedLink(toValidURL(element.link || ""));
  215. // if rendering embeddables explicitly disabled or
  216. // embedding documents via srcdoc (which doesn't seem to work for SVGs)
  217. // replace with a link instead
  218. if (
  219. renderConfig.renderEmbeddables === false ||
  220. embedLink?.type === "document"
  221. ) {
  222. const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
  223. anchorTag.setAttribute("href", normalizeLink(element.link || ""));
  224. anchorTag.setAttribute("target", "_blank");
  225. anchorTag.setAttribute("rel", "noopener noreferrer");
  226. anchorTag.style.borderRadius = `${radius}px`;
  227. embeddableNode.appendChild(anchorTag);
  228. } else {
  229. const foreignObject = svgRoot.ownerDocument!.createElementNS(
  230. SVG_NS,
  231. "foreignObject",
  232. );
  233. foreignObject.style.width = `${element.width}px`;
  234. foreignObject.style.height = `${element.height}px`;
  235. foreignObject.style.border = "none";
  236. const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
  237. div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
  238. div.style.width = "100%";
  239. div.style.height = "100%";
  240. const iframe = div.ownerDocument!.createElement("iframe");
  241. iframe.src = embedLink?.link ?? "";
  242. iframe.style.width = "100%";
  243. iframe.style.height = "100%";
  244. iframe.style.border = "none";
  245. iframe.style.borderRadius = `${radius}px`;
  246. iframe.style.top = "0";
  247. iframe.style.left = "0";
  248. iframe.allowFullscreen = true;
  249. div.appendChild(iframe);
  250. foreignObject.appendChild(div);
  251. embeddableNode.appendChild(foreignObject);
  252. }
  253. addToRoot(embeddableNode, element);
  254. break;
  255. }
  256. case "line":
  257. case "arrow": {
  258. const boundText = getBoundTextElement(element, elementsMap);
  259. const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
  260. if (boundText) {
  261. maskPath.setAttribute("id", `mask-${element.id}`);
  262. const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
  263. SVG_NS,
  264. "rect",
  265. );
  266. offsetX = offsetX || 0;
  267. offsetY = offsetY || 0;
  268. maskRectVisible.setAttribute("x", "0");
  269. maskRectVisible.setAttribute("y", "0");
  270. maskRectVisible.setAttribute("fill", "#fff");
  271. maskRectVisible.setAttribute(
  272. "width",
  273. `${element.width + 100 + offsetX}`,
  274. );
  275. maskRectVisible.setAttribute(
  276. "height",
  277. `${element.height + 100 + offsetY}`,
  278. );
  279. maskPath.appendChild(maskRectVisible);
  280. const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
  281. SVG_NS,
  282. "rect",
  283. );
  284. const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
  285. element,
  286. boundText,
  287. elementsMap,
  288. );
  289. const maskX = offsetX + boundTextCoords.x - element.x;
  290. const maskY = offsetY + boundTextCoords.y - element.y;
  291. maskRectInvisible.setAttribute("x", maskX.toString());
  292. maskRectInvisible.setAttribute("y", maskY.toString());
  293. maskRectInvisible.setAttribute("fill", "#000");
  294. maskRectInvisible.setAttribute("width", `${boundText.width}`);
  295. maskRectInvisible.setAttribute("height", `${boundText.height}`);
  296. maskRectInvisible.setAttribute("opacity", "1");
  297. maskPath.appendChild(maskRectInvisible);
  298. }
  299. const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  300. if (boundText) {
  301. group.setAttribute("mask", `url(#mask-${element.id})`);
  302. }
  303. group.setAttribute("stroke-linecap", "round");
  304. const shapes = ShapeCache.generateElementShape(element, renderConfig);
  305. shapes.forEach((shape) => {
  306. const node = roughSVGDrawWithPrecision(
  307. rsvg,
  308. shape,
  309. MAX_DECIMALS_FOR_SVG_EXPORT,
  310. );
  311. if (opacity !== 1) {
  312. node.setAttribute("stroke-opacity", `${opacity}`);
  313. node.setAttribute("fill-opacity", `${opacity}`);
  314. }
  315. node.setAttribute(
  316. "transform",
  317. `translate(${offsetX || 0} ${
  318. offsetY || 0
  319. }) rotate(${degree} ${cx} ${cy})`,
  320. );
  321. if (
  322. element.type === "line" &&
  323. isPathALoop(element.points) &&
  324. element.backgroundColor !== "transparent"
  325. ) {
  326. node.setAttribute("fill-rule", "evenodd");
  327. }
  328. group.appendChild(node);
  329. });
  330. const g = maybeWrapNodesInFrameClipPath(
  331. element,
  332. root,
  333. [group, maskPath],
  334. renderConfig.frameRendering,
  335. elementsMap,
  336. );
  337. if (g) {
  338. addToRoot(g, element);
  339. root.appendChild(g);
  340. } else {
  341. addToRoot(group, element);
  342. root.append(maskPath);
  343. }
  344. break;
  345. }
  346. case "freedraw": {
  347. const backgroundFillShape = ShapeCache.generateElementShape(
  348. element,
  349. renderConfig,
  350. );
  351. const node = backgroundFillShape
  352. ? roughSVGDrawWithPrecision(
  353. rsvg,
  354. backgroundFillShape,
  355. MAX_DECIMALS_FOR_SVG_EXPORT,
  356. )
  357. : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  358. if (opacity !== 1) {
  359. node.setAttribute("stroke-opacity", `${opacity}`);
  360. node.setAttribute("fill-opacity", `${opacity}`);
  361. }
  362. node.setAttribute(
  363. "transform",
  364. `translate(${offsetX || 0} ${
  365. offsetY || 0
  366. }) rotate(${degree} ${cx} ${cy})`,
  367. );
  368. node.setAttribute("stroke", "none");
  369. const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
  370. path.setAttribute("fill", element.strokeColor);
  371. path.setAttribute("d", getFreeDrawSvgPath(element));
  372. node.appendChild(path);
  373. const g = maybeWrapNodesInFrameClipPath(
  374. element,
  375. root,
  376. [node],
  377. renderConfig.frameRendering,
  378. elementsMap,
  379. );
  380. addToRoot(g || node, element);
  381. break;
  382. }
  383. case "image": {
  384. const width = Math.round(element.width);
  385. const height = Math.round(element.height);
  386. const fileData =
  387. isInitializedImageElement(element) && files[element.fileId];
  388. if (fileData) {
  389. // TODO set to `false` before merging
  390. const { reuseImages = true } = renderConfig;
  391. let symbolId = `image-${fileData.id}`;
  392. let uncroppedWidth = element.width;
  393. let uncroppedHeight = element.height;
  394. if (element.crop) {
  395. ({ width: uncroppedWidth, height: uncroppedHeight } =
  396. getUncroppedWidthAndHeight(element));
  397. symbolId = `image-crop-${fileData.id}-${hashString(
  398. `${uncroppedWidth}x${uncroppedHeight}`,
  399. )}`;
  400. }
  401. if (!reuseImages) {
  402. symbolId = `image-${element.id}`;
  403. }
  404. let symbol = svgRoot.querySelector(`#${symbolId}`);
  405. if (!symbol) {
  406. symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
  407. symbol.id = symbolId;
  408. const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
  409. image.setAttribute("href", fileData.dataURL);
  410. image.setAttribute("preserveAspectRatio", "none");
  411. if (element.crop || !reuseImages) {
  412. image.setAttribute("width", `${uncroppedWidth}`);
  413. image.setAttribute("height", `${uncroppedHeight}`);
  414. } else {
  415. image.setAttribute("width", "100%");
  416. image.setAttribute("height", "100%");
  417. }
  418. symbol.appendChild(image);
  419. root.prepend(symbol);
  420. }
  421. const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
  422. use.setAttribute("href", `#${symbolId}`);
  423. // in dark theme, revert the image color filter
  424. if (
  425. renderConfig.exportWithDarkMode &&
  426. fileData.mimeType !== MIME_TYPES.svg
  427. ) {
  428. use.setAttribute("filter", IMAGE_INVERT_FILTER);
  429. }
  430. let normalizedCropX = 0;
  431. let normalizedCropY = 0;
  432. if (element.crop) {
  433. const { width: uncroppedWidth, height: uncroppedHeight } =
  434. getUncroppedWidthAndHeight(element);
  435. normalizedCropX =
  436. element.crop.x / (element.crop.naturalWidth / uncroppedWidth);
  437. normalizedCropY =
  438. element.crop.y / (element.crop.naturalHeight / uncroppedHeight);
  439. }
  440. const adjustedCenterX = cx + normalizedCropX;
  441. const adjustedCenterY = cy + normalizedCropY;
  442. use.setAttribute("width", `${width + normalizedCropX}`);
  443. use.setAttribute("height", `${height + normalizedCropY}`);
  444. use.setAttribute("opacity", `${opacity}`);
  445. // We first apply `scale` transforms (horizontal/vertical mirroring)
  446. // on the <use> element, then apply translation and rotation
  447. // on the <g> element which wraps the <use>.
  448. // Doing this separately is a quick hack to to work around compositing
  449. // the transformations correctly (the transform-origin was not being
  450. // applied correctly).
  451. if (element.scale[0] !== 1 || element.scale[1] !== 1) {
  452. use.setAttribute(
  453. "transform",
  454. `translate(${adjustedCenterX} ${adjustedCenterY}) scale(${
  455. element.scale[0]
  456. } ${
  457. element.scale[1]
  458. }) translate(${-adjustedCenterX} ${-adjustedCenterY})`,
  459. );
  460. }
  461. const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  462. if (element.crop) {
  463. const mask = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
  464. mask.setAttribute("id", `mask-image-crop-${element.id}`);
  465. mask.setAttribute("fill", "#fff");
  466. const maskRect = svgRoot.ownerDocument!.createElementNS(
  467. SVG_NS,
  468. "rect",
  469. );
  470. maskRect.setAttribute("x", `${normalizedCropX}`);
  471. maskRect.setAttribute("y", `${normalizedCropY}`);
  472. maskRect.setAttribute("width", `${width}`);
  473. maskRect.setAttribute("height", `${height}`);
  474. mask.appendChild(maskRect);
  475. root.appendChild(mask);
  476. g.setAttribute("mask", `url(#${mask.id})`);
  477. }
  478. g.appendChild(use);
  479. g.setAttribute(
  480. "transform",
  481. `translate(${offsetX - normalizedCropX} ${
  482. offsetY - normalizedCropY
  483. }) rotate(${degree} ${adjustedCenterX} ${adjustedCenterY})`,
  484. );
  485. if (element.roundness) {
  486. const clipPath = svgRoot.ownerDocument!.createElementNS(
  487. SVG_NS,
  488. "clipPath",
  489. );
  490. clipPath.id = `image-clipPath-${element.id}`;
  491. const clipRect = svgRoot.ownerDocument!.createElementNS(
  492. SVG_NS,
  493. "rect",
  494. );
  495. const radius = getCornerRadius(
  496. Math.min(element.width, element.height),
  497. element,
  498. );
  499. clipRect.setAttribute("width", `${element.width}`);
  500. clipRect.setAttribute("height", `${element.height}`);
  501. clipRect.setAttribute("rx", `${radius}`);
  502. clipRect.setAttribute("ry", `${radius}`);
  503. clipPath.appendChild(clipRect);
  504. addToRoot(clipPath, element);
  505. g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`);
  506. }
  507. const clipG = maybeWrapNodesInFrameClipPath(
  508. element,
  509. root,
  510. [g],
  511. renderConfig.frameRendering,
  512. elementsMap,
  513. );
  514. addToRoot(clipG || g, element);
  515. }
  516. break;
  517. }
  518. // frames are not rendered and only acts as a container
  519. case "frame":
  520. case "magicframe": {
  521. if (
  522. renderConfig.frameRendering.enabled &&
  523. renderConfig.frameRendering.outline
  524. ) {
  525. const rect = document.createElementNS(SVG_NS, "rect");
  526. rect.setAttribute(
  527. "transform",
  528. `translate(${offsetX || 0} ${
  529. offsetY || 0
  530. }) rotate(${degree} ${cx} ${cy})`,
  531. );
  532. rect.setAttribute("width", `${element.width}px`);
  533. rect.setAttribute("height", `${element.height}px`);
  534. // Rounded corners
  535. rect.setAttribute("rx", FRAME_STYLE.radius.toString());
  536. rect.setAttribute("ry", FRAME_STYLE.radius.toString());
  537. rect.setAttribute("fill", "none");
  538. rect.setAttribute("stroke", FRAME_STYLE.strokeColor);
  539. rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
  540. addToRoot(rect, element);
  541. }
  542. break;
  543. }
  544. default: {
  545. if (isTextElement(element)) {
  546. const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  547. if (opacity !== 1) {
  548. node.setAttribute("stroke-opacity", `${opacity}`);
  549. node.setAttribute("fill-opacity", `${opacity}`);
  550. }
  551. node.setAttribute(
  552. "transform",
  553. `translate(${offsetX || 0} ${
  554. offsetY || 0
  555. }) rotate(${degree} ${cx} ${cy})`,
  556. );
  557. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  558. const lineHeightPx = getLineHeightInPx(
  559. element.fontSize,
  560. element.lineHeight,
  561. );
  562. const horizontalOffset =
  563. element.textAlign === "center"
  564. ? element.width / 2
  565. : element.textAlign === "right"
  566. ? element.width
  567. : 0;
  568. const verticalOffset = getVerticalOffset(
  569. element.fontFamily,
  570. element.fontSize,
  571. lineHeightPx,
  572. );
  573. const direction = isRTL(element.text) ? "rtl" : "ltr";
  574. const textAnchor =
  575. element.textAlign === "center"
  576. ? "middle"
  577. : element.textAlign === "right" || direction === "rtl"
  578. ? "end"
  579. : "start";
  580. for (let i = 0; i < lines.length; i++) {
  581. const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
  582. text.textContent = lines[i];
  583. text.setAttribute("x", `${horizontalOffset}`);
  584. text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`);
  585. text.setAttribute("font-family", getFontFamilyString(element));
  586. text.setAttribute("font-size", `${element.fontSize}px`);
  587. text.setAttribute("fill", element.strokeColor);
  588. text.setAttribute("text-anchor", textAnchor);
  589. text.setAttribute("style", "white-space: pre;");
  590. text.setAttribute("direction", direction);
  591. text.setAttribute("dominant-baseline", "alphabetic");
  592. node.appendChild(text);
  593. }
  594. const g = maybeWrapNodesInFrameClipPath(
  595. element,
  596. root,
  597. [node],
  598. renderConfig.frameRendering,
  599. elementsMap,
  600. );
  601. addToRoot(g || node, element);
  602. } else {
  603. // @ts-ignore
  604. throw new Error(`Unimplemented type ${element.type}`);
  605. }
  606. }
  607. }
  608. };
  609. export const renderSceneToSvg = (
  610. elements: readonly NonDeletedExcalidrawElement[],
  611. elementsMap: RenderableElementsMap,
  612. rsvg: RoughSVG,
  613. svgRoot: SVGElement,
  614. files: BinaryFiles,
  615. renderConfig: SVGRenderConfig,
  616. ) => {
  617. if (!svgRoot) {
  618. return;
  619. }
  620. // render elements
  621. elements
  622. .filter((el) => !isIframeLikeElement(el))
  623. .forEach((element) => {
  624. if (!element.isDeleted) {
  625. if (
  626. isTextElement(element) &&
  627. element.containerId &&
  628. elementsMap.has(element.containerId)
  629. ) {
  630. // will be rendered with the container
  631. return;
  632. }
  633. try {
  634. renderElementToSvg(
  635. element,
  636. elementsMap,
  637. rsvg,
  638. svgRoot,
  639. files,
  640. element.x + renderConfig.offsetX,
  641. element.y + renderConfig.offsetY,
  642. renderConfig,
  643. );
  644. const boundTextElement = getBoundTextElement(element, elementsMap);
  645. if (boundTextElement) {
  646. renderElementToSvg(
  647. boundTextElement,
  648. elementsMap,
  649. rsvg,
  650. svgRoot,
  651. files,
  652. boundTextElement.x + renderConfig.offsetX,
  653. boundTextElement.y + renderConfig.offsetY,
  654. renderConfig,
  655. );
  656. }
  657. } catch (error: any) {
  658. console.error(error);
  659. }
  660. }
  661. });
  662. // render embeddables on top
  663. elements
  664. .filter((el) => isIframeLikeElement(el))
  665. .forEach((element) => {
  666. if (!element.isDeleted) {
  667. try {
  668. renderElementToSvg(
  669. element,
  670. elementsMap,
  671. rsvg,
  672. svgRoot,
  673. files,
  674. element.x + renderConfig.offsetX,
  675. element.y + renderConfig.offsetY,
  676. renderConfig,
  677. );
  678. } catch (error: any) {
  679. console.error(error);
  680. }
  681. }
  682. });
  683. };