align svg exports with canvas exports

This commit is contained in:
Ryan Di 2024-12-06 19:00:30 +08:00
parent cd8bfb06b5
commit e49db1dd3c
8 changed files with 96 additions and 178 deletions

View File

@ -50,9 +50,6 @@ const ChartPreviewBtn = (props: {
}, },
files: null, files: null,
}, },
config: {
skipInliningFonts: true,
},
}); });
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();

View File

@ -140,9 +140,6 @@ const SingleLibraryItem = ({
}, },
files: null, files: null,
}, },
config: {
skipInliningFonts: true,
},
}); });
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();

View File

@ -19,10 +19,6 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
}, },
files: null, files: null,
}, },
config: {
renderEmbeddables: false,
skipInliningFonts: true,
},
}); });
}; };

View File

@ -29,14 +29,14 @@ import {
getInitializedImageElements, getInitializedImageElements,
updateImageCache, updateImageCache,
} from "../element/image"; } from "../element/image";
import { restore, restoreAppState } from "../data/restore"; import { restoreAppState } from "../data/restore";
import { import {
getElementsOverlappingFrame, getElementsOverlappingFrame,
getFrameLikeElements, getFrameLikeElements,
getFrameLikeTitle, getFrameLikeTitle,
getRootElements, getRootElements,
} from "../frame"; } from "../frame";
import { getNonDeletedElements, newTextElement } from "../element"; import { newTextElement } from "../element";
import { type Mutable } from "../utility-types"; import { type Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement } from "../element/typeChecks"; import { isFrameLikeElement } from "../element/typeChecks";
@ -164,13 +164,13 @@ type ExportToCanvasAppState = Partial<
Omit<AppState, "offsetTop" | "offsetLeft"> Omit<AppState, "offsetTop" | "offsetLeft">
>; >;
export type ExportToCanvasData = { export type ExportSceneData = {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState?: ExportToCanvasAppState; appState?: ExportToCanvasAppState;
files: BinaryFiles | null; files: BinaryFiles | null;
}; };
export type ExportToCanvasConfig = { export type ExportSceneConfig = {
theme?: Theme; theme?: Theme;
/** /**
* Canvas background. Valid values are: * Canvas background. Valid values are:
@ -339,8 +339,8 @@ const configExportDimension = async ({
data, data,
config, config,
}: { }: {
data: ExportToCanvasData; data: ExportSceneData;
config?: ExportToCanvasConfig; config?: ExportSceneConfig;
}) => { }) => {
// clone // clone
const cfg = Object.assign({}, config); const cfg = Object.assign({}, config);
@ -626,8 +626,8 @@ export const exportToCanvas = async ({
data, data,
config, config,
}: { }: {
data: ExportToCanvasData; data: ExportSceneData;
config?: ExportToCanvasConfig; config?: ExportSceneConfig;
}) => { }) => {
const { const {
config: cfg, config: cfg,
@ -706,7 +706,7 @@ export const exportToCanvas = async ({
}; };
type ExportToSvgConfig = Pick< type ExportToSvgConfig = Pick<
ExportToCanvasConfig, ExportSceneConfig,
"canvasBackgroundColor" | "padding" | "theme" | "exportingFrame" "canvasBackgroundColor" | "padding" | "theme" | "exportingFrame"
> & { > & {
/** /**
@ -721,108 +721,75 @@ export const exportToSvg = async ({
data, data,
config, config,
}: { }: {
data: { data: ExportSceneData;
elements: readonly NonDeletedExcalidrawElement[]; config?: ExportSceneConfig;
appState: { }) => {
exportBackground: boolean; const {
exportScale?: number; config: cfg,
viewBackgroundColor: string; normalizedPadding,
exportWithDarkMode?: boolean; exportWidth,
exportEmbedScene?: boolean; exportHeight,
frameRendering?: AppState["frameRendering"]; exportScale,
gridModeEnabled?: boolean; x,
}; y,
files: BinaryFiles | null; elementsForRender,
}; appState,
config?: ExportToSvgConfig;
}): Promise<SVGSVGElement> => {
// clone
const cfg = Object.assign({}, config);
cfg.exportingFrame = cfg.exportingFrame ?? null;
const { elements: restoredElements } = restore(
{ ...data, files: data.files || {} },
null,
null,
);
const elements = getNonDeletedElements(restoredElements);
const frameRendering = getFrameRenderingConfig(
cfg?.exportingFrame ?? null,
data.appState.frameRendering ?? null,
);
let {
exportWithDarkMode = false,
viewBackgroundColor,
exportScale = 1,
exportEmbedScene,
} = data.appState;
let padding = cfg.padding ?? 0;
const elementsForRender = prepareElementsForRender({
elements,
exportingFrame: cfg.exportingFrame,
exportWithDarkMode,
frameRendering, frameRendering,
}); } = await configExportDimension({ data, config });
if (cfg.exportingFrame) { const offsetX = -(x - normalizedPadding);
padding = 0; const offsetY = -(y - normalizedPadding);
const { elements } = data;
// initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg");
svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute(
"viewBox",
`0 0 ${exportWidth / exportScale} ${exportHeight / exportScale}`,
);
svgRoot.setAttribute("width", `${exportWidth}`);
svgRoot.setAttribute("height", `${exportHeight}`);
if (cfg.theme === THEME.DARK) {
svgRoot.setAttribute("filter", THEME_FILTER);
} }
const fontFaces = cfg.loadFonts
? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces
let metadata = ""; let metadata = "";
// we need to serialize the "original" elements before we put them through // we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids // the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) { if (appState.exportEmbedScene) {
try { try {
metadata = (await import("../data/image")).encodeSvgMetadata({ metadata = (await import("../data/image")).encodeSvgMetadata({
// when embedding scene, we want to embed the origionally supplied // when embedding scene, we want to embed the origionally supplied
// elements which don't contain the temp frame labels. // elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with // But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra. // only the elements that we're exporting, and no extra.
text: serializeAsJSON( text: serializeAsJSON(elements, appState, data.files || {}, "local"),
elements,
data.appState,
data.files || {},
"local",
),
}); });
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
} }
} }
let [minX, minY, width, height] = getCanvasSize( let exportContentClipPath = "";
cfg.exportingFrame if (cfg.width != null && cfg.height != null) {
? [cfg.exportingFrame] exportContentClipPath = `<clipPath id="content">
: getRootElements(elementsForRender), <rect x="${offsetX}" y="${offsetY}" width="${exportWidth}" height="${exportWidth}"></rect>
); </clipPath>`;
width += padding * 2;
height += padding * 2;
// initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg");
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}`);
if (exportWithDarkMode) {
svgRoot.setAttribute("filter", THEME_FILTER);
} }
const offsetX = -minX + padding;
const offsetY = -minY + padding;
const frameElements = getFrameLikeElements(elements);
let exportingFrameClipPath = ""; let exportingFrameClipPath = "";
const elementsMap = arrayToMap(elements); const elementsMap = arrayToMap(elements);
const frameElements = getFrameLikeElements(elements);
for (const frame of frameElements) { for (const frame of frameElements) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
const cx = (x2 - x1) / 2 - (frame.x - x1); const cx = (x2 - x1) / 2 - (frame.x - x1);
@ -844,36 +811,33 @@ export const exportToSvg = async ({
</clipPath>`; </clipPath>`;
} }
const fontFaces = !cfg?.skipInliningFonts
? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces
svgRoot.innerHTML = ` svgRoot.innerHTML = `
${SVG_EXPORT_TAG} ${SVG_EXPORT_TAG}
${metadata} ${metadata}
<defs> <defs>
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)} <style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}</style>
</style> ${exportContentClipPath}
${exportingFrameClipPath} ${exportingFrameClipPath}
</defs> </defs>
`; `;
// render background rect // render background rect
if (data.appState.exportBackground && viewBackgroundColor) { if (appState.exportBackground && appState.viewBackgroundColor) {
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect"); const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0"); rect.setAttribute("x", "0");
rect.setAttribute("y", "0"); rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`); rect.setAttribute("width", `${exportWidth / exportScale}`);
rect.setAttribute("height", `${height}`); rect.setAttribute("height", `${exportHeight / exportScale}`);
rect.setAttribute("fill", viewBackgroundColor); rect.setAttribute(
"fill",
cfg.canvasBackgroundColor || appState.viewBackgroundColor,
);
svgRoot.appendChild(rect); svgRoot.appendChild(rect);
} }
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
const renderEmbeddables = cfg.renderEmbeddables ?? false; // const renderEmbeddables = appState.embe ?? false;
renderSceneToSvg( renderSceneToSvg(
elementsForRender, elementsForRender,
@ -885,18 +849,18 @@ export const exportToSvg = async ({
offsetX, offsetX,
offsetY, offsetY,
isExporting: true, isExporting: true,
exportWithDarkMode, exportWithDarkMode: cfg.theme === THEME.DARK,
renderEmbeddables, renderEmbeddables: false,
frameRendering, frameRendering,
canvasBackgroundColor: viewBackgroundColor, canvasBackgroundColor: appState.viewBackgroundColor,
embedsValidationStatus: renderEmbeddables embedsValidationStatus: false
? new Map( ? new Map(
elementsForRender elementsForRender
.filter((element) => isFrameLikeElement(element)) .filter((element) => isFrameLikeElement(element))
.map((element) => [element.id, true]), .map((element) => [element.id, true]),
) )
: new Map(), : new Map(),
reuseImages: cfg?.reuseImages ?? true, reuseImages: true,
}, },
); );
@ -916,7 +880,7 @@ export const getCanvasSize = (
export { MIME_TYPES }; export { MIME_TYPES };
type ExportToBlobConfig = ExportToCanvasConfig & { type ExportToBlobConfig = ExportSceneConfig & {
mimeType?: string; mimeType?: string;
quality?: number; quality?: number;
}; };
@ -925,7 +889,7 @@ export const exportToBlob = async ({
data, data,
config, config,
}: { }: {
data: ExportToCanvasData; data: ExportSceneData;
config?: ExportToBlobConfig; config?: ExportToBlobConfig;
}): Promise<Blob> => { }): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = config || {}; let { mimeType = MIME_TYPES.png, quality } = config || {};
@ -990,7 +954,7 @@ export const exportToClipboard = async ({
data, data,
config, config,
}: { }: {
data: ExportToCanvasData; data: ExportSceneData;
} & ( } & (
| { type: "png"; config?: ExportToBlobConfig } | { type: "png"; config?: ExportToBlobConfig }
| { type: "svg"; config?: ExportToSvgConfig } | { type: "svg"; config?: ExportToSvgConfig }

View File

@ -6,7 +6,7 @@ exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active too
B --&gt; C{Let me think} B --&gt; C{Let me think}
C --&gt;|One| D[Laptop] C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone] C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="9" height="0" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>" C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="300" height="0" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
`; `;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = ` exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `

View File

@ -6,8 +6,8 @@ exports[`export > exporting svg containing transformed images > svg export outpu
<defs> <defs>
<style class="style-fonts"> <style class="style-fonts">
</style>
</style>
</defs> </defs>
<clipPath id="image-clipPath-id1" data-id="id1"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(20.710678118654755 20.710678118654755) rotate(315 50 50)" clip-path="url(#image-clipPath-id1)" data-id="id1"><use href="#image-file_A" width="100" height="100" opacity="1"></use></g><clipPath id="image-clipPath-id2" data-id="id2"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(120.71067811865476 20.710678118654755) rotate(45 25 25)" clip-path="url(#image-clipPath-id2)" data-id="id2"><use href="#image-file_A" width="50" height="50" opacity="1" transform="translate(25 25) scale(-1 1) translate(-25 -25)"></use></g><clipPath id="image-clipPath-id3" data-id="id3"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(20.710678118654755 120.71067811865476) rotate(45 50 50)" clip-path="url(#image-clipPath-id3)" data-id="id3"><use href="#image-file_A" width="100" height="100" opacity="1" transform="translate(50 50) scale(1 -1) translate(-50 -50)"></use></g><clipPath id="image-clipPath-id4" data-id="id4"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(120.71067811865476 120.71067811865476) rotate(315 25 25)" clip-path="url(#image-clipPath-id4)" data-id="id4"><use href="#image-file_A" width="50" height="50" opacity="1" transform="translate(25 25) scale(-1 -1) translate(-25 -25)"></use></g></svg>" <clipPath id="image-clipPath-id1" data-id="id1"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(20.710678118654755 20.710678118654755) rotate(315 50 50)" clip-path="url(#image-clipPath-id1)" data-id="id1"><use href="#image-file_A" width="100" height="100" opacity="1"></use></g><clipPath id="image-clipPath-id2" data-id="id2"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(120.71067811865476 20.710678118654755) rotate(45 25 25)" clip-path="url(#image-clipPath-id2)" data-id="id2"><use href="#image-file_A" width="50" height="50" opacity="1" transform="translate(25 25) scale(-1 1) translate(-25 -25)"></use></g><clipPath id="image-clipPath-id3" data-id="id3"><rect width="100" height="100" rx="25" ry="25"></rect></clipPath><g transform="translate(20.710678118654755 120.71067811865476) rotate(45 50 50)" clip-path="url(#image-clipPath-id3)" data-id="id3"><use href="#image-file_A" width="100" height="100" opacity="1" transform="translate(50 50) scale(1 -1) translate(-50 -50)"></use></g><clipPath id="image-clipPath-id4" data-id="id4"><rect width="50" height="50" rx="12.5" ry="12.5"></rect></clipPath><g transform="translate(120.71067811865476 120.71067811865476) rotate(315 25 25)" clip-path="url(#image-clipPath-id4)" data-id="id4"><use href="#image-file_A" width="50" height="50" opacity="1" transform="translate(25 25) scale(-1 -1) translate(-25 -25)"></use></g></svg>"

File diff suppressed because one or more lines are too long

View File

@ -117,9 +117,7 @@ describe("exportToSvg", () => {
}, },
}); });
expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot( expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(`null`);
`"_themeFilter_1883f3"`,
);
}); });
it("with exportPadding", async () => { it("with exportPadding", async () => {
@ -149,10 +147,12 @@ describe("exportToSvg", () => {
elements: ELEMENTS, elements: ELEMENTS,
appState: { appState: {
...DEFAULT_OPTIONS, ...DEFAULT_OPTIONS,
exportScale: SCALE,
}, },
files: null, files: null,
}, },
config: {
scale: SCALE,
},
}); });
expect(svgElement).toHaveAttribute( expect(svgElement).toHaveAttribute(