import { ExcalidrawElement, FileId } from "@zsviczian/excalidraw/types/excalidraw/element/types"; import { BinaryFileData } from "@zsviczian/excalidraw/types/excalidraw/types"; import { Mutable } from "@zsviczian/excalidraw/types/excalidraw/utility-types"; import { Notice } from "obsidian"; import { getEA } from "src/core"; import { ExcalidrawAutomate } from "src/shared/ExcalidrawAutomate"; import { cloneElement } from "src/utils/excalidrawAutomateUtils"; import { ExportSettings } from "src/view/ExcalidrawView"; import { nanoid } from "src/constants/constants"; import { svgToBase64 } from "../utils/utils"; /** * Creates a masked image from an Excalidraw scene. * * The scene must contain: * - One element.type="frame" element that defines the crop area * - One or more element.type="image" elements * - Zero or more non-image shape elements (rectangles, ellipses etc) that define the mask * * The class splits the scene into two parts: * 1. Images (managed in imageEA) * 2. Mask shapes (managed in maskEA) * * A transparent rectangle matching the combined bounding box is added to both * imageEA and maskEA to ensure consistent sizing between image and mask. * * For performance, if there is only one image, it is not rotated, and * its size matches the bounding box, * the image data is used directly from cache rather than regenerating. * * @example * const cropper = new CropImage(elements, files); * const pngBlob = await cropper.getCroppedPNG(); * cropper.destroy(); */ export class CropImage { private imageEA: ExcalidrawAutomate; private maskEA: ExcalidrawAutomate; private bbox: {topX: number, topY: number, width: number, height: number}; constructor ( private elements: ExcalidrawElement[], files: Map, ) { const imageEA = getEA() as ExcalidrawAutomate; this.imageEA = imageEA; const maskEA = getEA() as ExcalidrawAutomate; this.maskEA = maskEA; this.bbox = imageEA.getBoundingBox(elements); //this makes both the image and the mask the same size //Adding the bounding element first so it is at the bottom of the layers - does not override the image. this.setBoundingEl(imageEA, "transparent"); this.setBoundingEl(maskEA, "white"); //the bbox should not mask the image. White lets everything through. elements.forEach(el => { const newEl = cloneElement(el) as Mutable; if(el.type !== "image" && el.type !== "frame") { newEl.opacity = 100; maskEA.elementsDict[el.id] = newEl; } if(el.type === "image") { imageEA.elementsDict[el.id] = newEl; } }) Object.values(files).forEach(file => { imageEA.imagesDict[file.id] = file; }) } public destroy() { this.imageEA.destroy(); this.maskEA.destroy(); this.imageEA = null; this.maskEA = null; this.elements = null; this.bbox = null; } private setBoundingEl(ea: ExcalidrawAutomate, bgColor: string) { const {topX, topY, width, height} = this.bbox; ea.style.backgroundColor = bgColor; ea.style.strokeColor = "transparent"; //@ts-ignore: Setting this to string "0" will produce a rectangle with zero stroke width ea.style.strokeWidth = "0"; ea.style.strokeStyle = "solid"; ea.style.fillStyle = "solid"; ea.style.roughness = 0; ea.addRect(topX, topY, width, height); } private getViewBoxAndSize(): {viewBox: string, vbWidth: number, vbHeight: number, width: number, height: number} { const frames = this.elements.filter(el=>el.type === "frame"); if(frames.length > 1) { new Notice("Multiple frames are not supported for image cropping. Discarding frames from mask."); } const images = this.imageEA.getElements().filter(el=>el.type === "image"); const {x: frameX, y: frameY, width: frameWidth, height: frameHeight} = frames.length === 1 ? frames[0] : mapToXY(this.imageEA.getBoundingBox(images)); const {topX, topY, width, height} = this.bbox; return { viewBox: `${frameX-topX} ${frameY-topY} ${frameWidth} ${frameHeight}`, vbWidth: frameWidth, vbHeight: frameHeight, width, height, } } private async getMaskSVG():Promise<{style: string, mask: string}> { const exportSettings:ExportSettings = { withBackground: false, withTheme: false, isMask: false, } const maskSVG = await this.maskEA.createSVG(null,true,exportSettings,null,null,0); const defs = maskSVG.querySelector("defs"); const styleEl = maskSVG.querySelector("style"); const style = styleEl ? styleEl.outerHTML : ""; defs.parentElement.removeChild(defs); return {style, mask:maskSVG.innerHTML}; } private async getImage() { const exportSettings:ExportSettings = { withBackground: false, withTheme: false, isMask: false, } const images = this.imageEA.getElements().filter(el=>el.type === "image" && el.isDeleted === false); const isRotated = images.some(el=>el.angle !== 0); const imageDataURLs = Object.values(this.imageEA.imagesDict); if(!isRotated && images.length === 1 && imageDataURLs.length === 1) { const { width, height } = this.bbox; if(images[0].width === width && images[0].height === height) { //get image from the cache if mask is not bigger than the image, and if there is a single image element return imageDataURLs[0].dataURL; } } return await this.imageEA.createPNGBase64(null,1,exportSettings,null,null,0); } private async buildSVG(): Promise { if(this.imageEA.getElements().filter(el=>el.type === "image").length === 0) { new Notice("No image found. Cannot crop."); return; } const maskID = nanoid(); const imageID = nanoid(); const {viewBox, vbWidth, vbHeight, width, height} = this.getViewBoxAndSize(); const parser = new DOMParser(); const {style, mask} = await this.getMaskSVG(); const svgString = `\n` + `\n` + `${style}\n\n${mask}\n\n\n` + `\n`; return parser.parseFromString( svgString, "image/svg+xml", ).firstElementChild as SVGSVGElement } async getCroppedPNG(): Promise { //@ts-ignore const PLUGIN = app.plugins.plugins["obsidian-excalidraw-plugin"]; const svg = await this.buildSVG(); return new Promise((resolve, reject) => { //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/2026 const svgData = svg.outerHTML; //const svgData = new XMLSerializer().serializeToString(svg); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) { reject('Unable to get 2D context'); return; } canvas.width = svg.width.baseVal.value; canvas.height = svg.height.baseVal.value; const image = new Image(); image.onload = () => { context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0); canvas.toBlob( (blob) => { if (blob) { resolve(blob); } else { reject(new Error('Failed to convert to PNG')); } }, 'image/png', 1 // image quality (0 - 1) ); }; image.src = svgToBase64(svgData); }); } async getCroppedSVG() { return await this.buildSVG(); } } const mapToXY = ({topX, topY, width, height}: {topX: number, topY: number, width: number, height: number}): {x: number, y: number, width: number, height: number} => { return { x: topX, y: topY, width, height, } }