diff --git a/examples/with-script-in-browser/components/ExampleApp.tsx b/examples/with-script-in-browser/components/ExampleApp.tsx index 8ee104f6d0..b336c0662b 100644 --- a/examples/with-script-in-browser/components/ExampleApp.tsx +++ b/examples/with-script-in-browser/components/ExampleApp.tsx @@ -80,9 +80,7 @@ export default function ExampleApp({ excalidrawLib, }: AppProps) { const { - exportToCanvas, - exportToSvg, - exportToBlob, + exportScene, exportToClipboard, useHandleLibrary, MIME_TYPES, @@ -115,6 +113,10 @@ export default function ExampleApp({ const [canvasUrl, setCanvasUrl] = useState(""); const [exportWithDarkMode, setExportWithDarkMode] = useState(false); const [exportEmbedScene, setExportEmbedScene] = useState(false); + const exportConfig = { + theme: exportWithDarkMode ? "dark" : "light", + padding: 10, + } as const; const [theme, setTheme] = useState("light"); const [disableImageTool, setDisableImageTool] = useState(false); const [isCollaborating, setIsCollaborating] = useState(false); @@ -387,10 +389,12 @@ export default function ExampleApp({ return false; } await exportToClipboard({ - elements: excalidrawAPI.getSceneElements(), - appState: excalidrawAPI.getAppState(), - files: excalidrawAPI.getFiles(), type, + data: { + elements: excalidrawAPI.getSceneElements(), + appState: excalidrawAPI.getAppState(), + files: excalidrawAPI.getFiles(), + }, }); window.alert(`Copied to clipboard as ${type} successfully`); }; @@ -888,16 +892,14 @@ export default function ExampleApp({ if (!excalidrawAPI) { return; } - const svg = await exportToSvg({ - elements: excalidrawAPI?.getSceneElements(), - appState: { - ...initialData.appState, - exportWithDarkMode, - exportEmbedScene, - width: 300, - height: 100, + const svg = await exportScene({ + type: "svg", + data: { + elements: excalidrawAPI.getSceneElements(), + appState: initialData.appState, + files: excalidrawAPI.getFiles(), }, - files: excalidrawAPI?.getFiles(), + config: { ...exportConfig, embedScene: exportEmbedScene }, }); appRef.current.querySelector(".export-svg").innerHTML = svg.outerHTML; @@ -912,15 +914,14 @@ export default function ExampleApp({ if (!excalidrawAPI) { return; } - const blob = await exportToBlob({ - elements: excalidrawAPI?.getSceneElements(), - mimeType: "image/png", - appState: { - ...initialData.appState, - exportEmbedScene, - exportWithDarkMode, + const blob = await exportScene({ + type: "png", + data: { + elements: excalidrawAPI.getSceneElements(), + appState: initialData.appState, + files: excalidrawAPI.getFiles(), }, - files: excalidrawAPI?.getFiles(), + config: { ...exportConfig, embedScene: exportEmbedScene }, }); setBlobUrl(window.URL.createObjectURL(blob)); }} @@ -935,13 +936,14 @@ export default function ExampleApp({ if (!excalidrawAPI) { return; } - const canvas = await exportToCanvas({ - elements: excalidrawAPI.getSceneElements(), - appState: { - ...initialData.appState, - exportWithDarkMode, + const canvas = await exportScene({ + type: "canvas", + data: { + elements: excalidrawAPI.getSceneElements(), + appState: initialData.appState, + files: excalidrawAPI.getFiles(), }, - files: excalidrawAPI.getFiles(), + config: exportConfig, }); const ctx = canvas.getContext("2d")!; ctx.font = "30px Excalifont"; @@ -956,13 +958,14 @@ export default function ExampleApp({ if (!excalidrawAPI) { return; } - const canvas = await exportToCanvas({ - elements: excalidrawAPI.getSceneElements(), - appState: { - ...initialData.appState, - exportWithDarkMode, + const canvas = await exportScene({ + type: "canvas", + data: { + elements: excalidrawAPI.getSceneElements(), + appState: initialData.appState, + files: excalidrawAPI.getFiles(), }, - files: excalidrawAPI.getFiles(), + config: exportConfig, }); const ctx = canvas.getContext("2d")!; ctx.font = "30px Excalifont"; diff --git a/excalidraw-app/components/AI.tsx b/excalidraw-app/components/AI.tsx index 9a4472f107..cc8b28cb9c 100644 --- a/excalidraw-app/components/AI.tsx +++ b/excalidraw-app/components/AI.tsx @@ -1,12 +1,12 @@ import { DiagramToCodePlugin, - exportToBlob, + exportScene, getNonDeletedElements, getTextFromElements, - MIME_TYPES, parseSSEStream, TTDDialog, TTDStreamFetch, + THEME, } from "@excalidraw/excalidraw"; import { getDataURL } from "@excalidraw/excalidraw/data/blob"; import { safelyParseJSON } from "@excalidraw/common"; @@ -34,16 +34,17 @@ export const AIComponents = ({ ); } - const blob = await exportToBlob({ - elements: getNonDeletedElements(children), - appState: { - ...appState, - exportBackground: true, - viewBackgroundColor: appState.viewBackgroundColor, + const blob = await exportScene({ + type: "jpeg", + data: { + elements: getNonDeletedElements(children), + appState, + files: excalidrawAPI.getFiles(), + }, + config: { + exportingFrame: frame, + theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, }, - exportingFrame: frame, - files: excalidrawAPI.getFiles(), - mimeType: MIME_TYPES.jpg, }); const dataURL = await getDataURL(blob); diff --git a/packages/element/tests/cropElement.test.tsx b/packages/element/tests/cropElement.test.tsx index cab7fe35e7..1489e8cfa6 100644 --- a/packages/element/tests/cropElement.test.tsx +++ b/packages/element/tests/cropElement.test.tsx @@ -3,11 +3,7 @@ import { vi } from "vitest"; import { KEYS, cloneJSON } from "@excalidraw/common"; -import { - Excalidraw, - exportToCanvas, - exportToSvg, -} from "@excalidraw/excalidraw"; +import { Excalidraw, exportScene } from "@excalidraw/excalidraw"; import { actionFlipHorizontal, actionFlipVertical, @@ -532,23 +528,33 @@ describe("Cropping and other features", async () => { Keyboard.keyDown(KEYS.ESCAPE); const widthToHeightRatio = image.width / image.height; - const canvas = await exportToCanvas({ - elements: [image], - // @ts-ignore - appState: h.state, - files: h.app.files, - exportPadding: 0, + const canvas = await exportScene({ + type: "canvas", + data: { + elements: [image], + // @ts-ignore + appState: h.state, + files: h.app.files, + }, + config: { + padding: 0, + }, }); const exportedCanvasRatio = canvas.width / canvas.height; expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio); - const svg = await exportToSvg({ - elements: [image], - // @ts-ignore - appState: h.state, - files: h.app.files, - exportPadding: 0, + const svg = await exportScene({ + type: "svg", + data: { + elements: [image], + // @ts-ignore + appState: h.state, + files: h.app.files, + }, + config: { + padding: 0, + }, }); const svgWidth = svg.getAttribute("width"); const svgHeight = svg.getAttribute("height"); diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index cd4eab1f51..ab3b75908a 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -13,6 +13,33 @@ Please add the latest change on the top under the correct section. ## Unreleased +### Export API: `exportScene` (2026-10-18) [#12347](https://github.com/excalidraw/excalidraw/pull/12347) + +Breaking. `exportToCanvas`, `exportToBlob` and `exportToSvg` (in both `@excalidraw/excalidraw` and `@excalidraw/utils`) are replaced by a single `exportScene`, and every rendering option moved from `appState` into an explicit `config`. + +- `exportScene({ type, data, config })` with `type` `"canvas"`, `"png"`, `"jpeg"` or `"svg"` resolves to an `HTMLCanvasElement`, a `Blob` or an `SVGSVGElement`. `exportToClipboard({ type: "png" | "svg" | "json", data, config })` stays. Types: `ExportSceneData`, `ExportSceneConfig`, `ExportSceneOptions`. +- `data` is `{ elements, appState?, files? }`, the shape of a `.excalidraw` file. Only the scene settings a file stores are read from `appState`: `viewBackgroundColor` as the default background, and the grid settings that `embedScene` embeds. Any other key is ignored, so passing a live `appState` can't affect the output. +- Migration: + + | Before | Now | + | --- | --- | + | `exportToCanvas({ elements, appState, files, … })` | `exportScene({ type: "canvas", data: { elements, appState, files }, config })` | + | `exportToBlob({ …, mimeType, quality })` | `exportScene({ type: "png" \| "jpeg", …, config })` — `quality` is JPEG-only | + | `exportToSvg({ … })` | `exportScene({ type: "svg", … })` | + | `appState.exportBackground: false` | `config.canvasBackgroundColor: false` (a color string overrides the background; JPEG always gets one) | + | `appState.exportWithDarkMode` | `config.theme: "dark"` | + | `appState.exportScale` | `config.scale` (also multiplies a `maxWidthOrHeight` result) | + | `appState.exportEmbedScene` | `config.embedScene` (png, svg) | + | `appState.frameRendering` | `config.frames: { name?, outline?, clip? }` | + | `exportPadding` | `config.padding` (default 10, as before) | + | `getDimensions: (w, h) => ({ width, height, scale })` | `config.width`/`height`, `maxWidthOrHeight` or `widthOrHeight`, plus `config.scale` — a number, or `(width, height) => number` receiving the export size at scale 1 (padding included), e.g. to cap the output by a pixel budget | + | `maxWidthOrHeight`, `exportingFrame`, `renderEmbeddables`, `skipInliningFonts`, `reuseImages` | same names, in `config` | + +- New sizing options: `width`/`height` (only one given: the other follows the content's aspect ratio), `widthOrHeight` (scales the longest side to exactly that size), `fit: "contain" | "none"` and `position: "center" | "topLeft"` within a `width`/`height` frame, and `x`/`y`/`origin` to export a region (cropped, with padding on all sides). The size options are mutually exclusive in the types, and `fit`, `position` and `x`/`y` only combine with `width`/`height`. +- `config.environment` for non-browser hosts: `createCanvas` (e.g. `node-canvas`) and `loadFonts`. +- Canvas dimensions are rounded instead of truncated, so float noise no longer costs a pixel (a 20.5px frame-label band now rounds up), and are at least 1px (flat content with no padding used to produce an empty canvas that couldn't be encoded). +- In dark exports, frame names now use the editor's dark frame-name color (they were dark-filtered twice and came out lighter). + ### Selection API (2026-10-10) [#12258](https://github.com/excalidraw/excalidraw/pull/12258) - New `excalidrawAPI.selection`: `select(elements, opts?)`, `add(elements, opts?)`, `remove(elements, opts?)` and `clear(opts?)`, taking elements or their ids, one or many. Missing and deleted elements are skipped. @@ -20,12 +47,12 @@ Please add the latest change on the top under the correct section. - Options: `deep` selects the elements themselves rather than their groups, editing their group if they share one (as Ctrl/Cmd+click does); `includeLocked` selects locked elements too; `captureUpdate` records the change for undo as for `updateScene()` — by default it's recorded along with the next undoable change. - Like `updateScene()`, the change applies on the next render: read it back through `onChange()` or `getAppState()` after that. It works in view mode too, where the selection shows without the transform handles. -### Wheel navigation (2026-09-13) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX) +### Wheel navigation (2026-09-13) [#12099](https://github.com/excalidraw/excalidraw/pull/12099) - Scrolling while holding the wheel (middle) mouse button zooms the canvas around the pointer, the same as ctrl/cmd+wheel — a wheel-button pan can be zoomed one-handed, without reaching for a modifier. - New `appState.inputDevice` preference (`"auto" | "mouse" | "trackpad"`, default `"auto"`, persisted in browser storage) selecting the wheel mappings. With `"trackpad"` a plain wheel pans; with `"mouse"` a plain wheel zooms. Ctrl/cmd+wheel (how a pinch is delivered) zooms with either device. Shift+wheel pans horizontally, and ctrl/cmd+shift+wheel pans vertically. The wheel-button zoom above is unaffected. `"auto"` is reserved for detecting the device from the wheel events and resolves to `"trackpad"` until that is implemented (`resolveInputDevice()`). Set it from the new "Input device" radio in the main menu's preferences submenu, also exposed as `MainMenu.DefaultItems.Preferences.InputDevice`. -### Sticky notes (2026-09-06) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX) +### Sticky notes (2026-09-06) [#12064](https://github.com/excalidraw/excalidraw/pull/12064) - New `stickynote` element type and toolbar tool (`N`): an always-filled, flat-rendered note whose label auto-fits — the font shrinks from the user's size down to a minimum, and only then does the note grow in height (it never shrinks below the height the user gave it). Click to place a 250×250 note, drag to size it (previewed at the dragged size, snapped to a font-aware minimum on release); text editing starts right away. - Element model: the note carries `baseHeight` (the user's height; `height ≥ baseHeight`), its label carries `baseFontSize` (the user's font ceiling, `null`/absent on any other text) while `fontSize` holds the fitted size. Read a text element's user-facing size through `getBaseFontSize(text, elementsMap)`. @@ -38,11 +65,11 @@ Please add the latest change on the top under the correct section. ## Excalidraw API -### `setViewport` stops following a collaborator (2026-09-26) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX) +### `setViewport` stops following a collaborator (2026-09-26) [#12184](https://github.com/excalidraw/excalidraw/pull/12184) - `setViewport` now asks to stop following a collaborator whenever it navigates (`onUserFollow` with `action: "UNFOLLOW"`, if `userToFollow` is set), as a user's pan or zoom does. `setViewport(null)` (clearing a lock) and a target that doesn't resolve don't navigate, and leave following alone. -### `onDuplicate` can replace and veto duplicates (2026-09-18) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX) +### `onDuplicate` can replace and veto duplicates (2026-09-18) [#12124](https://github.com/excalidraw/excalidraw/pull/12124) - `props.onDuplicate` may now return new objects for the duplicated elements in every duplication path. Previously the editor kept using the objects it created, so on paste (and library insert) the frame assignment and bound text redraw went to objects that were no longer in the scene, and alt-drag dragged an element that wasn't in the scene. The returned changes are now merged into the editor's duplicates, so they're part of the duplication's own undo entry and durable increment (no follow-up `updateScene` needed). - When pasting or inserting onto a frame, the duplicates passed to `onDuplicate` already have their `frameId` set (and are ordered under the frame). diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index df5a261730..a1ed98d2b6 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -152,7 +152,6 @@ export const actionCopyAsSvg = register({ appState, app.files, { - ...appState, exportingFrame, name: app.getName(), }, @@ -219,7 +218,6 @@ export const actionCopyAsPng = register({ ); try { await exportCanvas("clipboard", exportedElements, appState, app.files, { - ...appState, exportingFrame, name: app.getName(), }); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 57fbd75f3b..18c06ac8c6 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2771,9 +2771,7 @@ class App extends React.Component { this.state, this.files, { - exportBackground: this.state.exportBackground, name: this.getName(), - viewBackgroundColor: this.state.viewBackgroundColor, exportingFrame: opts.exportingFrame, }, ) diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx index 48afbaa966..5d0e2bc137 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -1,4 +1,3 @@ -import { exportToCanvas } from "@excalidraw/utils/export"; import React, { useEffect, useRef, useState } from "react"; import { @@ -26,6 +25,7 @@ import { useCopyStatus } from "../hooks/useCopiedIndicator"; import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; +import { exportScene, getExportConfigFromAppState } from "../scene/export"; import { copyIcon, downloadIcon, helpIcon } from "./icons"; import { Dialog } from "./Dialog"; @@ -127,20 +127,25 @@ const ImageExportModal = ({ return requestId !== previewRenderRequestIdRef.current; }; - exportToCanvas({ - elements: exportedElements, - appState: { - ...appStateSnapshot, - name: projectName, - exportBackground: exportWithBackground, - exportWithDarkMode, - exportScale, - exportEmbedScene: embedScene, + exportScene({ + type: "canvas", + data: { + elements: exportedElements, + appState: appStateSnapshot, + files, + }, + config: { + ...getExportConfigFromAppState({ + ...appStateSnapshot, + exportBackground: exportWithBackground, + exportWithDarkMode, + // the preview fits `maxWidthOrHeight` + exportScale: 1, + }), + padding: DEFAULT_EXPORT_PADDING, + maxWidthOrHeight: Math.max(maxWidth, maxHeight), + exportingFrame, }, - files, - exportPadding: DEFAULT_EXPORT_PADDING, - maxWidthOrHeight: Math.max(maxWidth, maxHeight), - exportingFrame, }) .then(async (canvas) => { if (isStaleRequest()) { diff --git a/packages/excalidraw/components/PasteChartDialog.tsx b/packages/excalidraw/components/PasteChartDialog.tsx index 19c3271766..596afc59df 100644 --- a/packages/excalidraw/components/PasteChartDialog.tsx +++ b/packages/excalidraw/components/PasteChartDialog.tsx @@ -7,7 +7,7 @@ import type { ChartType } from "@excalidraw/element/types"; import { trackEvent } from "../analytics"; import { isSpreadsheetValidForChartType, renderSpreadsheet } from "../charts"; import { t } from "../i18n"; -import { exportToSvg } from "../scene/export"; +import { exportScene } from "../scene/export"; import { useUIAppState } from "../context/ui-appState"; @@ -72,18 +72,18 @@ const ChartPreviewBtn = (props: { const previewNode = previewRef.current!; (async () => { - svg = await exportToSvg( - elements, - { - exportBackground: false, - viewBackgroundColor: "#fff", - exportWithDarkMode: theme === "dark", + svg = await exportScene({ + type: "svg", + data: { + elements, + files: null, }, - null, // files - { + config: { skipInliningFonts: true, + theme, + canvasBackgroundColor: false, }, - ); + }); svg.querySelector(".style-fonts")?.remove(); previewNode.replaceChildren(); previewNode.appendChild(svg); @@ -134,18 +134,18 @@ const PlainTextPreviewBtn = (props: { const previewNode = previewRef.current!; (async () => { - const svg = await exportToSvg( - [textElement], - { - exportBackground: false, - viewBackgroundColor: "#fff", - exportWithDarkMode: theme === "dark", + const svg = await exportScene({ + type: "svg", + data: { + elements: [textElement], + files: null, }, - null, - { + config: { skipInliningFonts: true, + theme, + canvasBackgroundColor: false, }, - ); + }); svg.querySelector(".style-fonts")?.remove(); previewNode.replaceChildren(); previewNode.appendChild(svg); diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx index c9376895da..40e9c524b5 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -1,10 +1,10 @@ -import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export"; import { useCallback, useEffect, useRef, useState } from "react"; import { EDITOR_LS_KEYS, EXPORT_DATA_TYPES, MIME_TYPES, + THEME, VERSIONS, chunk, getExportSource, @@ -13,6 +13,7 @@ import { import { EditorLocalStorage } from "../data/EditorLocalStorage"; import { canvasToBlob, resizeImageFile } from "../data/blob"; import { t } from "../i18n"; +import { exportScene } from "../scene/export"; import { Dialog } from "./Dialog"; import DialogActionButton from "./DialogActionButton"; @@ -62,10 +63,15 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => { // draw items // --------------------------------------------------------------------------- for (const [index, item] of libraryItems.entries()) { - const itemCanvas = await exportToCanvas({ - elements: item.elements, - files: null, - maxWidthOrHeight: BOX_SIZE, + const itemCanvas = await exportScene({ + type: "canvas", + data: { + elements: item.elements, + files: null, + }, + config: { + maxWidthOrHeight: BOX_SIZE, + }, }); const { width, height } = itemCanvas; @@ -126,15 +132,17 @@ const SingleLibraryItem = ({ return; } (async () => { - const svg = await exportToSvg({ - elements: libItem.elements, - appState: { - ...appState, - viewBackgroundColor: "#fff", - exportBackground: true, + const svg = await exportScene({ + type: "svg", + data: { + elements: libItem.elements, + files: null, + }, + config: { + skipInliningFonts: true, + canvasBackgroundColor: "#fff", + theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, }, - files: null, - skipInliningFonts: true, }); node.innerHTML = svg.outerHTML; })(); diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts index 6f92f99702..acf38dac9a 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -1,12 +1,8 @@ -import { - DEFAULT_EXPORT_PADDING, - EDITOR_LS_KEYS, - THEME, -} from "@excalidraw/common"; +import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common"; import { convertToExcalidrawElements } from "@excalidraw/element"; -import { exportToCanvas } from "@excalidraw/utils"; +import { exportScene } from "@excalidraw/utils"; import type { NonDeletedExcalidrawElement, @@ -104,15 +100,18 @@ export const convertMermaidToExcalidraw = async ({ files, }; - const canvas = await exportToCanvas({ - elements: data.current.elements, - files: data.current.files, - exportPadding: DEFAULT_EXPORT_PADDING, - maxWidthOrHeight: - Math.max(parent.offsetWidth, parent.offsetHeight) * - window.devicePixelRatio, - appState: { - exportWithDarkMode: theme === THEME.DARK, + const canvas = await exportScene({ + type: "canvas", + data: { + elements: data.current.elements, + files: data.current.files, + }, + config: { + padding: DEFAULT_EXPORT_PADDING, + maxWidthOrHeight: + Math.max(parent.offsetWidth, parent.offsetHeight) * + window.devicePixelRatio, + theme, }, }); diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index eef9d286f7..d98fc2ff51 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -29,7 +29,7 @@ import { import { t } from "../i18n"; import { getSelectedElements, isSomeElementSelected } from "../scene"; -import { exportToCanvas, exportToSvg } from "../scene/export"; +import { exportScene, getExportConfigFromAppState } from "../scene/export"; import { canvasToBlob } from "./blob"; import { fileSave } from "./filesystem"; @@ -101,16 +101,12 @@ export const exportCanvas = async ( appState: AppState, files: BinaryFiles, { - exportBackground, exportPadding = DEFAULT_EXPORT_PADDING, - viewBackgroundColor, name = appState.name || DEFAULT_FILENAME, fileHandle = null, exportingFrame = null, }: { - exportBackground: boolean; exportPadding?: number; - viewBackgroundColor: string; /** filename, if applicable */ name?: string; fileHandle?: FileSystemFileHandle | null; @@ -120,20 +116,26 @@ export const exportCanvas = async ( if (elements.length === 0) { throw new Error(t("alerts.cannotExportEmptyCanvas")); } + + const config = { + ...getExportConfigFromAppState(appState), + padding: exportPadding, + exportingFrame, + }; + if (type === "svg" || type === "clipboard-svg") { - const svgPromise = exportToSvg( - elements, - { - exportBackground, - exportWithDarkMode: appState.exportWithDarkMode, - viewBackgroundColor, - exportPadding, - exportScale: appState.exportScale, - exportEmbedScene: appState.exportEmbedScene && type === "svg", + const svgPromise = exportScene({ + type: "svg", + data: { + elements, + appState, + files, }, - files, - { exportingFrame }, - ); + config: { + ...config, + embedScene: appState.exportEmbedScene && type === "svg", + }, + }); if (type === "svg") { return fileSave( @@ -163,11 +165,14 @@ export const exportCanvas = async ( } } - const tempCanvas = exportToCanvas(elements, appState, files, { - exportBackground, - viewBackgroundColor, - exportPadding, - exportingFrame, + const tempCanvas = exportScene({ + type: "canvas", + data: { + elements, + appState, + files, + }, + config, }); if (type === "png") { diff --git a/packages/excalidraw/data/resave.ts b/packages/excalidraw/data/resave.ts index abeeb16a68..aca88d9ebc 100644 --- a/packages/excalidraw/data/resave.ts +++ b/packages/excalidraw/data/resave.ts @@ -28,8 +28,6 @@ export const resaveAsImageWithScene = async ( let { elements, appState, files } = await data; - const { exportBackground, viewBackgroundColor } = appState; - appState = { ...appState, exportEmbedScene: true, @@ -42,8 +40,6 @@ export const resaveAsImageWithScene = async ( ); await exportCanvas(fileHandleType, exportedElements, appState, files, { - exportBackground, - viewBackgroundColor, name: filename, fileHandle, exportingFrame, diff --git a/packages/excalidraw/hooks/useLibraryItemSvg.ts b/packages/excalidraw/hooks/useLibraryItemSvg.ts index cb6d4dc325..f1ef7915ae 100644 --- a/packages/excalidraw/hooks/useLibraryItemSvg.ts +++ b/packages/excalidraw/hooks/useLibraryItemSvg.ts @@ -1,7 +1,6 @@ -import { exportToSvg } from "@excalidraw/utils/export"; import { useEffect, useState } from "react"; -import { COLOR_PALETTE } from "@excalidraw/common"; +import { exportScene } from "../scene/export"; import { atom, useAtom } from "../editor-jotai"; @@ -12,17 +11,16 @@ export type SvgCache = Map; export const libraryItemSvgsCache = atom(new Map()); const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => { - // TODO should pass theme (appState.exportWithDark) - we're still using - // CSS filter here - return await exportToSvg({ - elements, - appState: { - exportBackground: false, - viewBackgroundColor: COLOR_PALETTE.white, + return await exportScene({ + type: "svg", + data: { + elements, + files: null, + }, + config: { + skipInliningFonts: true, + canvasBackgroundColor: false, }, - files: null, - renderEmbeddables: false, - skipInliningFonts: true, }); }; diff --git a/packages/excalidraw/index-node.ts b/packages/excalidraw/index-node.ts index 3b94991493..0249ba7eec 100644 --- a/packages/excalidraw/index-node.ts +++ b/packages/excalidraw/index-node.ts @@ -1,5 +1,4 @@ -import { getDefaultAppState } from "./appState"; -import { exportToCanvas } from "./scene/export"; +import { exportScene } from "./scene/export"; const fs = require("fs"); @@ -59,26 +58,24 @@ const elements = [ registerFont("./public/Virgil.woff2", { family: "Virgil" }); registerFont("./public/Cascadia.woff2", { family: "Cascadia" }); -const canvas = exportToCanvas( - elements as any, - { - ...getDefaultAppState(), - offsetTop: 0, - offsetLeft: 0, - width: 0, - height: 0, - }, - {}, // files - { - exportBackground: true, - viewBackgroundColor: "#ffffff", - }, - createCanvas, -); +(async () => { + const canvas = await exportScene({ + type: "canvas", + data: { + elements: elements as any, + appState: {}, + files: {}, + }, + config: { + canvasBackgroundColor: "#ffffff", + environment: { createCanvas }, + }, + }); -const out = fs.createWriteStream("test.png"); -const stream = (canvas as any).createPNGStream(); -stream.pipe(out); -out.on("finish", () => { - console.info("test.png was created."); -}); + const out = fs.createWriteStream("test.png"); + const stream = (canvas as any).createPNGStream(); + stream.pipe(out); + out.on("finish", () => { + console.info("test.png was created."); + }); +})(); diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 217c4e782a..3f66b76367 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -421,12 +421,13 @@ export { export { reconcileElements } from "./data/reconcile"; -export { - exportToCanvas, - exportToBlob, - exportToSvg, - exportToClipboard, -} from "@excalidraw/utils/export"; +export { exportScene, exportToClipboard } from "./scene/export"; + +export type { + ExportSceneData, + ExportSceneConfig, + ExportSceneOptions, +} from "./scene/export"; export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json"; export { diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index 65cfeb3d5b..4307e502ba 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -325,6 +325,18 @@ const _renderStaticScene = ({ ); } + if (renderConfig.clipToFrame) { + const frame = renderConfig.clipToFrame; + context.beginPath(); + context.rect( + frame.x + appState.scrollX, + frame.y + appState.scrollY, + frame.width, + frame.height, + ); + context.clip(); + } + const groupsToBeAddedToFrame = new Set(); visibleElements.forEach((element) => { diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 9216e42722..84300e9c01 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -1,24 +1,27 @@ import rough from "roughjs/bin/rough"; import { - DEFAULT_EXPORT_PADDING, FRAME_STYLE, FONT_FAMILY, SVG_NS, THEME, MIME_TYPES, EXPORT_DATA_TYPES, + COLOR_WHITE, + DEFAULT_EXPORT_PADDING, arrayToMap, distance, getFontString, toBrandedType, applyDarkModeFilter, + removeDarkModeFilter, } from "@excalidraw/common"; import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; import { getInitializedImageElements, + getNonDeletedElements, updateImageCache, } from "@excalidraw/element"; @@ -39,28 +42,52 @@ import { type Mutable } from "@excalidraw/common/utility-types"; import { newTextElement } from "@excalidraw/element"; -import type { Bounds } from "@excalidraw/common"; - import type { ExcalidrawFrameLikeElement, ExcalidrawTextElement, NonDeleted, NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, + Theme, } from "@excalidraw/element/types"; -import { getDefaultAppState } from "../appState"; +import { cleanAppStateForExport } from "../appState"; import { base64ToString, decode, encode, stringToBase64 } from "../data/encode"; import { serializeAsJSON } from "../data/json"; +import { restoreAppState, restoreElements } from "../data/restore"; +import { encodePngMetadata } from "../data/image"; import { Fonts } from "../fonts"; import { renderStaticScene } from "../renderer/staticScene"; import { renderSceneToSvg } from "../renderer/staticSvgScene"; +import { + copyBlobToClipboardAsPng, + copyTextToSystemClipboard, + copyToClipboard, +} from "../clipboard"; + import type { RenderableElementsMap } from "./types"; -import type { AppState, BinaryFiles } from "../types"; +import type { AppState, BinaryFiles, NormalizedZoomValue } from "../types"; + +// Default minimum export size in pixels +const DEFAULT_SMALLEST_EXPORT_SIZE = 20; + +/** + * Padding is taken from the target size when fitting into one: keep at least + * `DEFAULT_SMALLEST_EXPORT_SIZE` of each target dimension for the content. + */ +const clampPadding = (padding: number, targets: readonly number[]) => + Math.max( + 0, + Math.min( + padding, + ...targets.map((target) => (target - DEFAULT_SMALLEST_EXPORT_SIZE) / 2), + ), + ); +const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue; const truncateText = ( element: NonDeleted, @@ -82,9 +109,9 @@ const truncateText = ( if (metrics.width > maxWidth) { // we iterate from the right, removing characters one by one instead - // of bulding the string up. This assumes that it's more likely + // of building the string up. This assumes that it's more likely // your frame names will overflow by not that many characters - // (if ever), so it sohuld be faster this way. + // (if ever), so it should be faster this way. for (let i = text.length; i > 0; i--) { const newText = `${text.slice(0, i)}...`; if (ctx.measureText(newText).width <= maxWidth) { @@ -104,7 +131,7 @@ const truncateText = ( */ const addFrameLabelsAsTextElements = ( elements: readonly NonDeletedExcalidrawElement[], - opts: Pick, + opts: { theme: Theme }, ) => { const nextElements: NonDeletedExcalidrawElement[] = []; for (const element of elements) { @@ -117,9 +144,13 @@ const addFrameLabelsAsTextElements = ( fontSize: FRAME_STYLE.nameFontSize, lineHeight: FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"], - strokeColor: opts.exportWithDarkMode - ? FRAME_STYLE.nameColorDarkTheme - : FRAME_STYLE.nameColorLightTheme, + strokeColor: + opts.theme === THEME.DARK + ? // the renderer applies the dark mode filter to element + // colors, so revert it upfront to end up with the color + // the editor uses for frame names + removeDarkModeFilter(FRAME_STYLE.nameColorDarkTheme) + : FRAME_STYLE.nameColorLightTheme, text: getFrameLikeTitle(element), }); textElement.y -= textElement.height; @@ -136,27 +167,24 @@ const addFrameLabelsAsTextElements = ( const getFrameRenderingConfig = ( exportingFrame: ExcalidrawFrameLikeElement | null, - frameRendering: AppState["frameRendering"] | null, -): AppState["frameRendering"] => { - frameRendering = frameRendering || getDefaultAppState().frameRendering; - return { - enabled: exportingFrame ? true : frameRendering.enabled, - outline: exportingFrame ? false : frameRendering.outline, - name: exportingFrame ? false : frameRendering.name, - clip: exportingFrame ? true : frameRendering.clip, - }; -}; + frames: ExportSceneBaseConfig["frames"], +): AppState["frameRendering"] => ({ + enabled: true, + outline: exportingFrame ? false : frames?.outline ?? true, + name: exportingFrame ? false : frames?.name ?? true, + clip: exportingFrame ? true : frames?.clip ?? true, +}); const prepareElementsForRender = ({ elements, exportingFrame, frameRendering, - exportWithDarkMode, + theme, }: { elements: readonly NonDeletedExcalidrawElement[]; exportingFrame: ExcalidrawFrameLikeElement | null | undefined; frameRendering: AppState["frameRendering"]; - exportWithDarkMode: AppState["exportWithDarkMode"]; + theme: Theme; }) => { let nextElements: readonly NonDeletedExcalidrawElement[]; @@ -167,9 +195,7 @@ const prepareElementsForRender = ({ arrayToMap(elements), ); } else if (frameRendering.enabled && frameRendering.name) { - nextElements = addFrameLabelsAsTextElements(elements, { - exportWithDarkMode, - }); + nextElements = addFrameLabelsAsTextElements(elements, { theme }); } else { nextElements = elements; } @@ -177,43 +203,328 @@ const prepareElementsForRender = ({ return nextElements; }; -export const exportToCanvas = async ( - elements: readonly NonDeletedExcalidrawElement[], - appState: AppState, - files: BinaryFiles, - { - exportBackground, - exportPadding = DEFAULT_EXPORT_PADDING, - viewBackgroundColor, - exportingFrame, - }: { - exportBackground: boolean; - exportPadding?: number; - viewBackgroundColor: string; - exportingFrame?: NonDeleted | null; - }, - createCanvas: ( - width: number, - height: number, - ) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => { - const canvas = document.createElement("canvas"); - canvas.width = width * appState.exportScale; - canvas.height = height * appState.exportScale; - return { canvas, scale: appState.exportScale }; - }, - loadFonts: () => Promise = async () => { - await Fonts.loadElementsFonts(elements); - }, -) => { - // load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace) - await loadFonts(); +// --------------------------------------------------------------------------- +// Types for the new API +// --------------------------------------------------------------------------- + +export type ExportSceneData = { + elements: readonly NonDeletedExcalidrawElement[]; + /** + * The scene settings a `.excalidraw` file stores (`viewBackgroundColor`, + * grid settings) — not editor state. Used as the default background + * (`canvasBackgroundColor`) and embedded with the scene (`embedScene`). + * Anything else passed in is ignored; rendering options go to `config`. + */ + appState?: ReturnType; + files?: BinaryFiles | null; +}; + +type ExportEnvironment = { + /** + * Creates the canvas to export into. + * + * Do not set `canvas.width/height` or modify the canvas context as that's + * handled by Excalidraw. + * + * Defaults to `document.createElement("canvas")`. + */ + createCanvas?: () => HTMLCanvasElement; + /** + * Loads the fonts the exported elements use, before rendering. + * + * Defaults to loading them through the browser's + * [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace). + */ + loadFonts?: () => Promise; +}; + +type ExportSceneBaseConfig = { + /** + * @default "light" + */ + theme?: Theme; + /** + * Canvas background. Valid values are: + * + * - `undefined` - the background of "appState.viewBackgroundColor" is used. + * - `false` - no background is used (transparent). Not supported for JPEG, + * which falls back to the default. + * - `string` - should be a valid CSS color. + * + * Specified in light-theme terms: `theme: "dark"` transforms it like any + * other scene color. Only affects the rendered image, not the embedded + * scene (`embedScene`). + * + * @default undefined + */ + canvasBackgroundColor?: string | false; + /** + * Canvas padding in pixels. Affected by `scale`. + * + * When `fit` is `none`, padding is added around the content bounding box, + * or around the `width`/`height` frame if set — in which case the content + * may overflow the frame, thus right or bottom padding may be ignored. + * + * When `fit` is `contain`, or `maxWidthOrHeight`/`widthOrHeight` is set, + * padding is subtracted from the target size (so the export doesn't exceed + * it, `scale` aside), and serves as a minimum distance between the content + * and the canvas edges — it may be exceeded on one axis to maintain the + * aspect ratio. It is recommended to set `position` to `center` when using + * `fit: "contain"`. + * + * @default 10 + */ + padding?: number; + // ------------------------------------------------------------------------- + /** + * A multiplier to increase/decrease the frame dimensions + * (content resolution). + * + * For example, if your canvas is 300x150 and you set scale to 2, the + * resulting size will be 600x300. + * + * Can be a function, which receives the export dimensions at scale 1 + * (padding included) and returns the multiplier — e.g. to cap the output + * by a pixel budget. + * + * @default 1 + */ + scale?: number | ((width: number, height: number) => number); + /** + * Host environment overrides, e.g. for test environments or Node.js. + */ + environment?: ExportEnvironment; + + exportingFrame?: NonDeleted | null; + /** + * Which frame decorations to render. Ignored when exporting a single frame + * (`exportingFrame`), which renders neither its name nor outline, and clips + * to it. + * + * @default { name: true, outline: true, clip: true } + */ + frames?: { name?: boolean; outline?: boolean; clip?: boolean }; +}; + +/** + * The ways to size the export — only one can be used. The positioning options + * (`fit`, `position`, `x`, `y`, `origin`) belong to `width`/`height` (or no + * size option). + */ +type ExportSizeOptions = { + /** + * Makes sure the canvas content fits into a frame of width/height no larger + * than this value, while maintaining the aspect ratio. + * + * Final dimensions can get smaller/larger if used in conjunction with + * `scale`. + * + * Can't be combined with `width`/`height`, `widthOrHeight`, or the + * positioning options (`fit`, `position`, `x`, `y`, `origin`). + */ + maxWidthOrHeight?: number; + /** + * Scale the canvas content to be exactly this many pixels wide/tall, + * maintaining the aspect ratio. + * + * Final dimensions can get smaller/larger if used in conjunction with + * `scale`. + * + * Can't be combined with `width`/`height`, `maxWidthOrHeight`, or the + * positioning options (`fit`, `position`, `x`, `y`, `origin`). + */ + widthOrHeight?: number; + /** + * Width of the frame. Supply `x` or `y` if you want to offset the canvas + * content. + * + * If `width` omitted but `height` supplied, `width` is calculated from the + * content's aspect ratio when `fit` is `contain` (the default), or is the + * content bounding box width when `fit` is `none`. + * + * Defaults to the content bounding box width when both `width` and `height` + * are omitted. + * + * Can't be combined with `maxWidthOrHeight` or `widthOrHeight`. + */ + width?: number; + /** + * Height of the frame. + * + * If `height` omitted but `width` supplied, `height` is calculated from the + * content's aspect ratio when `fit` is `contain` (the default), or is the + * content bounding box height when `fit` is `none`. + * + * Defaults to the content bounding box height when both `width` and `height` + * are omitted. + * + * Can't be combined with `maxWidthOrHeight` or `widthOrHeight`. + */ + height?: number; + // ------------------------------------------------------------------------- + /** + * Left edge of the exported region (`padding` is added around it). By + * default the coordinate is relative to the canvas. You can switch to + * content coordinates by setting `origin` to `content`. + * + * Defaults to the `x` position of the content bounding box. + * + * Can't be combined with `maxWidthOrHeight` or `widthOrHeight`, and is + * ignored with `fit: "contain"`, so setting `x` or `y` makes `fit` default + * to `none` (cropping the region). + */ + x?: number; + /** + * Top edge of the exported region (`padding` is added around it). By + * default the coordinate is relative to the canvas. You can switch to + * content coordinates by setting `origin` to `content`. + * + * Defaults to the `y` position of the content bounding box. + * + * Can't be combined with `maxWidthOrHeight` or `widthOrHeight`, and is + * ignored with `fit: "contain"`, so setting `x` or `y` makes `fit` default + * to `none` (cropping the region). + */ + y?: number; + /** + * Indicates the coordinate system of the `x` and `y` values. + * + * - `canvas` - `x` and `y` are relative to the canvas [0, 0] position. + * - `content` - `x` and `y` are relative to the content bounding box. + * + * Can't be combined with `maxWidthOrHeight` or `widthOrHeight`. + * + * @default "canvas" + */ + origin?: "canvas" | "content"; + /** + * How the content should be scaled into the `width`/`height` frame. + * + * Behavior aligns with the `object-fit` CSS property. + * + * - `none` - no scaling. + * - `contain` - scale to fit the frame. Includes `padding`. + * + * Can't be combined with `maxWidthOrHeight` or `widthOrHeight`. + * + * @default "contain" if `width` or `height` is set (and `x`/`y` aren't), + * otherwise "none" + */ + fit?: "none" | "contain"; + /** + * When either `x` or `y` are not specified, indicates how the canvas should + * be aligned on the respective axis. + * + * - `topLeft` - canvas aligned to top left. + * - `center` - canvas is centered on the axis which is not specified + * (or both). + * + * Can't be combined with `maxWidthOrHeight` or `widthOrHeight`. + * + * @default "center" + */ + position?: "center" | "topLeft"; +}; + +type ExportSizeOption = Pick< + ExportSizeOptions, + K +> & { + // remapping over `keyof` (rather than `Exclude`) keeps the + // properties' JSDoc in editor hovers & completions + [P in keyof ExportSizeOptions as P extends K ? never : P]?: never; +}; + +type ExportSizeConfig = + | ExportSizeOption< + "width" | "height" | "fit" | "position" | "x" | "y" | "origin" + > + | ExportSizeOption<"maxWidthOrHeight"> + | ExportSizeOption<"widthOrHeight">; + +export type ExportSceneConfig = ExportSceneBaseConfig & ExportSizeConfig; + +/** + * The export config the editor's export settings translate to. Internal: hosts + * pass their own config. + */ +export const getExportConfigFromAppState = ( + appState: Pick< + AppState, + | "exportWithDarkMode" + | "exportBackground" + | "viewBackgroundColor" + | "exportScale" + | "frameRendering" + >, +) => ({ + theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, + canvasBackgroundColor: appState.exportBackground + ? appState.viewBackgroundColor + : (false as const), + scale: appState.exportScale, + frames: appState.frameRendering.enabled + ? appState.frameRendering + : { name: false, outline: false, clip: false }, +}); + +// --------------------------------------------------------------------------- +// Internal helper to configure export dimensions +// --------------------------------------------------------------------------- + +const configExportDimension = async ({ + data, + config, +}: { + data: ExportSceneData; + config?: ExportSceneConfig; +}) => { + // clone (widened, as the size options get normalized below) + const cfg: ExportSceneBaseConfig & ExportSizeOptions = Object.assign( + {}, + config, + ); + + const { exportingFrame } = cfg; + + // restore elements (normalizes & drops invisible elements) and filter out + // deleted ones. The type contract requires non-deleted elements already, + // but types alone don't protect JS consumers passing raw scene data. + // These are also what SVG export embeds (`embedScene`). + const elements = getNonDeletedElements( + restoreElements(data.elements, null, { deleteInvisibleElements: true }), + ); + + // initialize defaults + // --------------------------------------------------------------------------- + + // only the scene settings a file stores are taken from `data.appState`, so + // that editor state passed in (e.g. a live AppState) can't affect the + // output; everything else the renderers read is a default + const appState = restoreAppState( + cleanAppStateForExport(data.appState ?? {}), + null, + ); + + const theme = cfg.theme ?? THEME.LIGHT; + + // the background the scene is rendered against. Used even when the + // background isn't painted, to fake holes in elements (e.g. outline + // arrowheads covering the arrow's shaft), so they keep looking the same. + const backgroundColor = + cfg.canvasBackgroundColor || appState.viewBackgroundColor || COLOR_WHITE; + // "transparent" = no background (also when the scene's background is set + // to transparent) + const paintedBackgroundColor = + cfg.canvasBackgroundColor === false ? "transparent" : backgroundColor; const frameRendering = getFrameRenderingConfig( exportingFrame ?? null, - appState.frameRendering ?? null, + cfg.frames, ); - // for canvas export, don't clip if exporting a specific frame as it would - // clip the corners of the content + // when exporting a frame, everything drawn is clipped to the frame as a + // whole, with square corners (see `exportToCanvas`/`exportToSvg`) — the + // per-element frame clips would round the corners of the content if (exportingFrame) { frameRendering.clip = false; } @@ -221,29 +532,272 @@ export const exportToCanvas = async ( const elementsForRender = prepareElementsForRender({ elements, exportingFrame, - exportWithDarkMode: appState.exportWithDarkMode, + theme, frameRendering, }); if (exportingFrame) { - exportPadding = 0; + cfg.padding = 0; } - const [minX, minY, width, height] = getCanvasSize( + // treat non-positive dimension constraints as unset (matches the previous + // API behavior where `maxWidthOrHeight: 0` was ignored), otherwise they'd + // result in degenerate (zero/negative) canvas dimensions + if (cfg.maxWidthOrHeight != null && cfg.maxWidthOrHeight <= 0) { + cfg.maxWidthOrHeight = undefined; + } + if (cfg.widthOrHeight != null && cfg.widthOrHeight <= 0) { + cfg.widthOrHeight = undefined; + } + + // an explicit `x`/`y` region is cropped rather than scaled by default + cfg.fit = + cfg.fit ?? + (cfg.x == null && + cfg.y == null && + (cfg.width != null || + cfg.height != null || + cfg.maxWidthOrHeight != null || + cfg.widthOrHeight != null) + ? "contain" + : "none"); + + cfg.padding = cfg.padding ?? DEFAULT_EXPORT_PADDING; + + cfg.origin = cfg.origin ?? "canvas"; + cfg.position = cfg.position ?? "center"; + + if (cfg.maxWidthOrHeight != null && cfg.widthOrHeight != null) { + if (!import.meta.env.PROD) { + console.warn("`maxWidthOrHeight` is ignored when `widthOrHeight` is set"); + } + cfg.maxWidthOrHeight = undefined; + } + + if ( + (cfg.x != null || cfg.y != null) && + (cfg.fit === "contain" || + cfg.maxWidthOrHeight != null || + cfg.widthOrHeight != null) + ) { + if (!import.meta.env.PROD) { + console.warn( + '`x`/`y` are ignored when the content is scaled to fit (`fit: "contain"`, `maxWidthOrHeight`, or `widthOrHeight`)', + ); + } + cfg.x = undefined; + cfg.y = undefined; + } + + // --------------------------------------------------------------------------- + + // load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace) + if (cfg.environment?.loadFonts) { + await cfg.environment.loadFonts(); + } else { + await Fonts.loadElementsFonts(elements); + } + + // value used to scale the canvas context. By default, we use this to + // make the canvas fit into the frame (e.g. for `cfg.fit` set to `contain`). + // If `cfg.scale` is set, we multiply the resulting canvasScale by it to + // scale the output further. + let exportScale = 1; + + const origCanvasSize = getCanvasSize( exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), - exportPadding, ); - const { canvas, scale = 1 } = createCanvas(width, height); + // variables for original content bounding box + const [origX, origY, origWidth, origHeight] = origCanvasSize; + // variables for target bounding box + let [x, y, width, height] = origCanvasSize; - const defaultAppState = getDefaultAppState(); + x = cfg.x ?? x; + y = cfg.y ?? y; + width = cfg.width ?? width; + height = cfg.height ?? height; + + // clamp against the target being fitted into (not the content: a flat + // content axis mustn't zero the padding), before deriving from it + if (cfg.widthOrHeight != null) { + cfg.padding = clampPadding(cfg.padding, [cfg.widthOrHeight]); + } else if (cfg.maxWidthOrHeight != null) { + cfg.padding = clampPadding(cfg.padding, [cfg.maxWidthOrHeight]); + } else if (cfg.fit === "contain") { + cfg.padding = clampPadding( + cfg.padding, + cfg.width != null || cfg.height != null + ? [cfg.width, cfg.height].filter((size): size is number => size != null) + : [width, height], + ); + } + + // with `contain`, derive a missing `width`/`height` from the content's + // aspect ratio (keeping the padding the same on all sides) + if (cfg.fit === "contain") { + if (cfg.width != null && cfg.height == null && origWidth > 0) { + height = + ((cfg.width - cfg.padding * 2) * origHeight) / origWidth + + cfg.padding * 2; + } else if (cfg.height != null && cfg.width == null && origHeight > 0) { + width = + ((cfg.height - cfg.padding * 2) * origWidth) / origHeight + + cfg.padding * 2; + } + } + + if (cfg.maxWidthOrHeight != null || cfg.widthOrHeight != null) { + if (cfg.padding) { + if (cfg.maxWidthOrHeight != null) { + cfg.maxWidthOrHeight -= cfg.padding * 2; + } else if (cfg.widthOrHeight != null) { + cfg.widthOrHeight -= cfg.padding * 2; + } + } + + const max = Math.max(width, height); + if (cfg.widthOrHeight != null) { + // calculate by how much do we need to scale the canvas to fit into the + // target dimension (e.g. target: max 50px, actual: 70x100px => scale: 0.5) + exportScale = cfg.widthOrHeight / max; + } else if (cfg.maxWidthOrHeight != null) { + exportScale = cfg.maxWidthOrHeight < max ? cfg.maxWidthOrHeight / max : 1; + } + + width *= exportScale; + height *= exportScale; + } else if (cfg.fit === "contain") { + width -= cfg.padding * 2; + height -= cfg.padding * 2; + + // scale the orig canvas to fit in the target region (a content axis of + // zero extent, e.g. a horizontal line's height, doesn't constrain it) + exportScale = Math.min( + origWidth > 0 ? width / origWidth : Infinity, + origHeight > 0 ? height / origHeight : Infinity, + ); + if (!Number.isFinite(exportScale)) { + exportScale = 1; + } + } + + x = cfg.x ?? origX; + y = cfg.y ?? origY; + + // if we switch to "content" coords, we need to offset cfg-supplied + // coords by the x/y of content bounding box + if (cfg.origin === "content") { + if (cfg.x != null) { + x += origX; + } + if (cfg.y != null) { + y += origY; + } + } + + // Centering the content to the frame. + // We divide width/height by canvasScale so that we calculate in the original + // aspect ratio dimensions. + // Only on the axes not pinned by `x`/`y`, so that a region gets padding on + // all sides. + if (cfg.position === "center") { + if (cfg.x == null) { + x -= width / exportScale / 2 - origWidth / 2; + } + if (cfg.y == null) { + y -= height / exportScale / 2 - origHeight / 2; + } + } + + // rescale padding based on current canvasScale factor so that the resulting + // padding is kept the same as supplied by user (with the exception of + // `cfg.scale` being set, which also scales the padding) + const normalizedPadding = cfg.padding / exportScale; + + // a `scale` function sizes the export by its dimensions at scale 1 + const scale = + typeof cfg.scale === "function" + ? cfg.scale(width + cfg.padding * 2, height + cfg.padding * 2) + : cfg.scale ?? 1; + + // scale the whole frame by `scale` (on top of whatever canvasScale we + // calculated above) + exportScale *= scale; + + width *= scale; + height *= scale; + + const exportWidth = width + cfg.padding * 2 * scale; + const exportHeight = height + cfg.padding * 2 * scale; + + return { + config: cfg, + // the restored & non-deleted-filtered elements — use these (not + // `data.elements`) for all rendering purposes + elements, + normalizedPadding, + contentWidth: width, + contentHeight: height, + exportWidth, + exportHeight, + exportScale, + x, + y, + elementsForRender, + appState, + frameRendering, + backgroundColor, + paintedBackgroundColor, + theme, + }; +}; + +// --------------------------------------------------------------------------- +// exportToCanvas +// --------------------------------------------------------------------------- + +const exportToCanvas = async ({ + data, + config, +}: { + data: ExportSceneData; + config?: ExportSceneConfig; +}) => { + const { + config: cfg, + elements, + normalizedPadding, + contentWidth: width, + contentHeight: height, + exportWidth, + exportHeight, + exportScale, + x, + y, + elementsForRender, + appState, + frameRendering, + backgroundColor, + paintedBackgroundColor, + theme, + } = await configExportDimension({ data, config }); + + const canvas = cfg.environment?.createCanvas + ? cfg.environment.createCanvas() + : document.createElement("canvas"); + + // round rather than letting the canvas truncate, which would turn float + // noise (e.g. 28.999999999999996) into a pixel lost + canvas.width = Math.max(1, Math.round(exportWidth)); + canvas.height = Math.max(1, Math.round(exportHeight)); const { imageCache } = await updateImageCache({ imageCache: new Map(), fileIds: getInitializedImageElements(elementsForRender).map( (element) => element.fileId, ), - files, + files: data.files || {}, }); renderStaticScene({ @@ -256,19 +810,25 @@ export const exportToCanvas = async ( arrayToMap(syncInvalidIndices(elements)), ), visibleElements: elementsForRender, - scale, + scale: exportScale, appState: { ...appState, frameRendering, - viewBackgroundColor: exportBackground ? viewBackgroundColor : null, - scrollX: -minX + exportPadding, - scrollY: -minY + exportPadding, - zoom: defaultAppState.zoom, + width, + height, + offsetLeft: 0, + offsetTop: 0, + scrollX: -x + normalizedPadding, + scrollY: -y + normalizedPadding, + zoom: { value: DEFAULT_ZOOM_VALUE }, shouldCacheIgnoreZoom: false, - theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, + theme, + // "transparent" makes bootstrapCanvas clear the canvas + viewBackgroundColor: paintedBackgroundColor, }, renderConfig: { - canvasBackgroundColor: viewBackgroundColor, + canvasBackgroundColor: backgroundColor, + clipToFrame: cfg.exportingFrame ?? null, imageCache, renderGrid: false, isExporting: true, @@ -276,13 +836,44 @@ export const exportToCanvas = async ( embedsValidationStatus: new Map(), elementsPendingErasure: new Set(), pendingFlowchartNodes: null, - theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, + theme, }, }); return canvas; }; +// --------------------------------------------------------------------------- +// exportToSvg +// --------------------------------------------------------------------------- + +type ExportToSvgConfig = Pick< + ExportSceneBaseConfig, + | "canvasBackgroundColor" + | "padding" + | "theme" + | "exportingFrame" + | "scale" + | "frames" +> & + ExportSizeConfig & { + /** + * Host environment overrides, e.g. for test environments or Node.js. + */ + environment?: Pick; + /** + * if true, all embeddables passed in will be rendered when possible. + */ + renderEmbeddables?: boolean; + skipInliningFonts?: true; + reuseImages?: boolean; + /** + * Embeds the scene into the SVG, so that it can be imported back into + * Excalidraw. + */ + embedScene?: boolean; + }; + const createHTMLComment = (text: string) => { // surrounding with spaces to maintain prettified consistency with previous // iterations @@ -290,61 +881,34 @@ const createHTMLComment = (text: string) => { return document.createComment(` ${text} `); }; -export const exportToSvg = async ( - elements: readonly NonDeletedExcalidrawElement[], - appState: { - exportBackground: boolean; - exportPadding?: number; - exportScale?: number; - viewBackgroundColor: string; - exportWithDarkMode?: boolean; - exportEmbedScene?: boolean; - frameRendering?: AppState["frameRendering"]; - }, - files: BinaryFiles | null, - opts?: { - /** - * if true, all embeddables passed in will be rendered when possible. - */ - renderEmbeddables?: boolean; - exportingFrame?: NonDeleted | null; - skipInliningFonts?: true; - reuseImages?: boolean; - }, -): Promise => { - const frameRendering = getFrameRenderingConfig( - opts?.exportingFrame ?? null, - appState.frameRendering ?? null, - ); - - let { - exportPadding = DEFAULT_EXPORT_PADDING, - exportWithDarkMode = false, - viewBackgroundColor, - exportScale = 1, - exportEmbedScene, - } = appState; - - const { exportingFrame = null } = opts || {}; - - const elementsForRender = prepareElementsForRender({ +const exportToSvg = async ({ + data, + config, +}: { + data: ExportSceneData; + config?: ExportToSvgConfig; +}) => { + const { + config: cfg, elements, - exportingFrame, - exportWithDarkMode, + normalizedPadding, + exportWidth, + exportHeight, + exportScale, + x, + y, + elementsForRender, + appState, frameRendering, - }); + backgroundColor, + paintedBackgroundColor, + theme, + } = await configExportDimension({ data, config }); - if (exportingFrame) { - exportPadding = 0; - } + const offsetX = -(x - normalizedPadding); + const offsetY = -(y - normalizedPadding); - const [minX, minY, width, height] = getCanvasSize( - exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), - exportPadding, - ); - - const offsetX = -minX + exportPadding; - const offsetY = -minY + exportPadding; + const exportWithDarkMode = theme === THEME.DARK; // --------------------------------------------------------------------------- // initialize SVG root element @@ -354,9 +918,12 @@ export const exportToSvg = async ( svgRoot.setAttribute("version", "1.1"); svgRoot.setAttribute("xmlns", SVG_NS); - svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`); - svgRoot.setAttribute("width", `${width * exportScale}`); - svgRoot.setAttribute("height", `${height * exportScale}`); + svgRoot.setAttribute( + "viewBox", + `0 0 ${exportWidth / exportScale} ${exportHeight / exportScale}`, + ); + svgRoot.setAttribute("width", `${exportWidth}`); + svgRoot.setAttribute("height", `${exportHeight}`); const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs"); @@ -375,15 +942,16 @@ export const exportToSvg = async ( // we need to serialize the "original" elements before we put them through // the tempScene hack which duplicates and regenerates ids - if (exportEmbedScene) { + if (config?.embedScene) { try { encodeSvgBase64Payload({ metadataElement, - // when embedding scene, we want to embed the origionally supplied - // elements which don't contain the temp frame labels. - // But it also requires that the exportToSvg is being supplied with - // only the elements that we're exporting, and no extra. - payload: serializeAsJSON(elements, appState, files || {}, "local"), + // when embedding scene, we want to embed the (restored) supplied + // elements which don't contain the temp frame labels. Restoring + // keeps the ids stable. But it also requires that the exportToSvg is + // being supplied with only the elements that we're exporting, and + // no extra. + payload: serializeAsJSON(elements, appState, data.files || {}, "local"), }); } catch (error: any) { console.error(error); @@ -395,6 +963,13 @@ export const exportToSvg = async ( // --------------------------------------------------------------------------- const frameElements = getFrameLikeElements(elements); + const { exportingFrame } = cfg; + if ( + exportingFrame && + !frameElements.some((frame) => frame.id === exportingFrame.id) + ) { + frameElements.push(exportingFrame); + } if (frameElements.length) { const elementsMap = arrayToMap(elements); @@ -421,7 +996,7 @@ export const exportToSvg = async ( rect.setAttribute("width", `${frame.width}`); rect.setAttribute("height", `${frame.height}`); - if (!exportingFrame) { + if (!cfg.exportingFrame) { rect.setAttribute("rx", `${FRAME_STYLE.radius}`); rect.setAttribute("ry", `${FRAME_STYLE.radius}`); } @@ -436,9 +1011,10 @@ export const exportToSvg = async ( // inline font faces // --------------------------------------------------------------------------- - const fontFaces = !opts?.skipInliningFonts - ? await Fonts.generateFontFaceDeclarations(elements) - : []; + const fontFaces = + config?.skipInliningFonts !== true + ? await Fonts.generateFontFaceDeclarations(elements) + : []; const delimiter = "\n "; // 6 spaces @@ -455,15 +1031,15 @@ export const exportToSvg = async ( // --------------------------------------------------------------------------- // render background rect - if (appState.exportBackground && viewBackgroundColor) { + if (paintedBackgroundColor !== "transparent") { const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect"); rect.setAttribute("x", "0"); rect.setAttribute("y", "0"); - rect.setAttribute("width", `${width}`); - rect.setAttribute("height", `${height}`); + rect.setAttribute("width", `${exportWidth / exportScale}`); + rect.setAttribute("height", `${exportHeight / exportScale}`); rect.setAttribute( "fill", - applyDarkModeFilter(viewBackgroundColor, exportWithDarkMode), + applyDarkModeFilter(paintedBackgroundColor, exportWithDarkMode), ); svgRoot.appendChild(rect); } @@ -474,14 +1050,23 @@ export const exportToSvg = async ( const rsvg = rough.svg(svgRoot); - const renderEmbeddables = opts?.renderEmbeddables ?? false; + const renderEmbeddables = config?.renderEmbeddables ?? false; + + // when exporting a frame, clip everything drawn to it (its clip path has + // square corners), as the export may be larger than the frame + let elementsRoot: SVGElement = svgRoot; + if (cfg.exportingFrame) { + elementsRoot = svgRoot.ownerDocument.createElementNS(SVG_NS, "g"); + elementsRoot.setAttribute("clip-path", `url(#${cfg.exportingFrame.id})`); + svgRoot.appendChild(elementsRoot); + } renderSceneToSvg( elementsForRender, toBrandedType(arrayToMap(elementsForRender)), rsvg, - svgRoot, - files || {}, + elementsRoot, + data.files || {}, { offsetX, offsetY, @@ -489,7 +1074,7 @@ export const exportToSvg = async ( exportWithDarkMode, renderEmbeddables, frameRendering, - canvasBackgroundColor: viewBackgroundColor, + canvasBackgroundColor: backgroundColor, embedsValidationStatus: renderEmbeddables ? new Map( elementsForRender @@ -497,8 +1082,8 @@ export const exportToSvg = async ( .map((element) => [element.id, true]), ) : new Map(), - reuseImages: opts?.reuseImages ?? true, - theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT, + reuseImages: config?.reuseImages ?? true, + theme, }, ); @@ -507,6 +1092,10 @@ export const exportToSvg = async ( return svgRoot; }; +// --------------------------------------------------------------------------- +// SVG payload encoding/decoding +// --------------------------------------------------------------------------- + export const encodeSvgBase64Payload = ({ payload, metadataElement, @@ -562,26 +1151,153 @@ export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => { throw new Error("INVALID"); }; +// --------------------------------------------------------------------------- +// getCanvasSize +// --------------------------------------------------------------------------- + // calculate smallest area to fit the contents in const getCanvasSize = ( elements: readonly NonDeletedExcalidrawElement[], - exportPadding: number, -): Bounds => { +): [x: number, y: number, width: number, height: number] => { const [minX, minY, maxX, maxY] = getCommonBounds(elements); - const width = distance(minX, maxX) + exportPadding * 2; - const height = distance(minY, maxY) + exportPadding * 2; + const width = distance(minX, maxX); + const height = distance(minY, maxY); return [minX, minY, width, height]; }; -export const getExportSize = ( - elements: readonly NonDeletedExcalidrawElement[], - exportPadding: number, - scale: number, -): [number, number] => { - const [, , width, height] = getCanvasSize(elements, exportPadding).map( - (dimension) => Math.trunc(dimension * scale), - ); +// --------------------------------------------------------------------------- +// exportToBlob +// --------------------------------------------------------------------------- - return [width, height]; +export { MIME_TYPES }; + +type ExportToPngConfig = ExportSceneConfig & { + /** + * Embeds the scene into the PNG metadata, so that it can be imported back + * into Excalidraw. + */ + embedScene?: boolean; +}; + +type ExportToJpegConfig = ExportSceneConfig & { + /** + * Encoding quality between 0 and 1. + * + * @default 0.92 + */ + quality?: number; +}; + +const exportToBlob = async ( + opts: { data: ExportSceneData } & ( + | { type: "png"; config?: ExportToPngConfig } + | { type: "jpeg"; config?: ExportToJpegConfig } + ), +): Promise => { + const { data } = opts; + let config: ExportSceneConfig | undefined = opts.config; + + // JPEG has no alpha channel (transparent pixels would turn black) + if (opts.type === "jpeg" && config?.canvasBackgroundColor === false) { + console.warn( + `"canvasBackgroundColor: false" is not supported for JPEG, using the default background`, + ); + config = { ...config, canvasBackgroundColor: undefined }; + } + + const canvas = await exportToCanvas({ data, config }); + + return new Promise((resolve, reject) => { + canvas.toBlob( + async (blob) => { + if (!blob) { + return reject(new Error("couldn't export to blob")); + } + if (opts.type === "png" && opts.config?.embedScene) { + blob = await encodePngMetadata({ + blob, + metadata: serializeAsJSON( + // NOTE as long as we're using the Scene hack, we need to ensure + // we pass the original, uncloned elements when serializing + // so that we keep ids stable (restoring keeps the ids) + restoreElements(data.elements, null), + data.appState ?? {}, + data.files || {}, + "local", + ), + }); + } + resolve(blob); + }, + opts.type === "png" ? MIME_TYPES.png : MIME_TYPES.jpg, + opts.type === "jpeg" ? opts.config?.quality || 0.92 : undefined, + ); + }); +}; + +// --------------------------------------------------------------------------- +// exportScene +// --------------------------------------------------------------------------- + +export type ExportSceneOptions = { data: ExportSceneData } & ( + | { type: "canvas"; config?: ExportSceneConfig } + | { type: "png"; config?: ExportToPngConfig } + | { type: "jpeg"; config?: ExportToJpegConfig } + | { type: "svg"; config?: ExportToSvgConfig } +); + +/** + * Exports the scene to a canvas, a PNG or JPEG blob, or an SVG element. + */ +export function exportScene( + opts: Extract, +): Promise; +export function exportScene( + opts: Extract, +): Promise; +export function exportScene( + opts: Extract, +): Promise; +export function exportScene( + opts: ExportSceneOptions, +): Promise; +export function exportScene( + opts: ExportSceneOptions, +): Promise { + switch (opts.type) { + case "canvas": + return exportToCanvas(opts); + case "png": + case "jpeg": + return exportToBlob(opts); + case "svg": + return exportToSvg(opts); + } +} + +// --------------------------------------------------------------------------- +// exportToClipboard +// --------------------------------------------------------------------------- + +export const exportToClipboard = async ( + opts: { data: ExportSceneData } & ( + | { type: "png"; config?: ExportToPngConfig } + | { type: "svg"; config?: ExportToSvgConfig } + | { type: "json"; config?: never } + ), +) => { + if (opts.type === "svg") { + const svg = await exportScene(opts); + await copyTextToSystemClipboard(svg.outerHTML); + } else if (opts.type === "png") { + await copyBlobToClipboardAsPng(exportScene(opts)); + } else if (opts.type === "json") { + await copyToClipboard( + getNonDeletedElements(restoreElements(opts.data.elements, null)), + opts.data.files ?? null, + ); + } else { + throw new Error("Invalid export type"); + } }; diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 52d4439b32..eb2f507caa 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -1,6 +1,7 @@ import type { UserIdleState, EditorInterface } from "@excalidraw/common"; import type { ExcalidrawElement, + ExcalidrawFrameLikeElement, NonDeletedElementsMap, NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, @@ -27,6 +28,8 @@ export type RenderableElementsMap = NonDeletedElementsMap & export type StaticCanvasRenderConfig = { canvasBackgroundColor: AppState["viewBackgroundColor"]; + /** export only: clips everything drawn to this frame (square corners) */ + clipToFrame?: ExcalidrawFrameLikeElement | null; // extra options passed to the renderer // --------------------------------------------------------------------------- imageCache: AppClassProperties["imageCache"]; diff --git a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap index f74342ad2f..c838f8a2f4 100644 --- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Test > should open mermaid popup when active tool is mermaid 1`] = `""`; +exports[`Test > should open mermaid popup when active tool is mermaid 1`] = `""`; exports[`Test > should show error in preview when mermaid library throws error 1`] = ` "flowchart TD diff --git a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap index 475a336842..19213cfc28 100644 --- a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`export > export svg-embedded scene > svg-embdedded scene export output 1`] = ` -"eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTwY7TMFx1MDAxML3zXHUwMDE1UfaK2LRcYlx1MDAwZb3twq7Yw1wiRJE4IFx1MDAwZW48TUZ1bcuebFuqSnxcdTAwMDY3fpFPYOyEuE6XVorkN555b96Mjy+KoqSDhXJRlLCvhULpxK58XHUwMDE58CdwXHUwMDFljebQPJ696VxcXHUwMDFkb7ZEdnF9rVxmJ7TG0+J1VVV9XHUwMDEyKNiCJs/XvvG5KI7xy1x1MDAxMZQh9SZei8A/XoI9JXTPUDWeXHUwMDBl2WmHklpGZm9HqFx1MDAwNWxayjGhXHUwMDFiXHUwMDA1WaInZzbwzijjXHUwMDAy49VcZsI/ka5EvWmc6bRcdTAwMWPvkFx1MDAxM9pb4biZdG+NSi3pXHUwMDEwq7NcdTAwMWbsVjnh+DpInE/w/2UxadNq8MGw2YhcdTAwMWEraqTQ/KxKXVx1MDAwNIX2QUZvvydNTmzhIZirO6VGXHUwMDE4tYT9XHUwMDE0jC1cdTAwMGVsWcBcdTAwMDPITEFcdTAwMWH+XHUwMDA19tHoOrdcdTAwMTf9e547xVx1MDAxMmuhPCRjXHUwMDAz4V3aiYy0s1LQhLd2cIEp1JtpLu/e5lx1MDAxOb64S+zyn9+/fp6NzWha4o8gel6dXHLdw32KZOVDxr3YolxuM3iTlb9R2Fx1MDAwNF9KXHUwMDA167PVYHNcYvk9jGEyNkVrridQg7tcdTAwMWOUcdigXHUwMDE26suz0kVH5jP4XlwiuVx1MDAwZc5dgVx1MDAwZuP2v5onlUqsQH0yXHUwMDFlqZ9goIuxXHUwMDEzf+PilMLaJbHRXHUwMDFj7Vx1MDAxZigvXHUwMDE3yolHPUZgk1x1MDAwN1x1MDAxMXo0XHUwMDEy7rRYqelcdTAwMDDKJ4Td7eVbulrH39DVMLvHTlx1MDAxMS55b+ogMyzHMeg7RX380GCATn9cdTAwMDFWrz23In0=