import { FileId } from "@zsviczian/excalidraw/types/element/types"; import { BinaryFileData, DataURL } from "@zsviczian/excalidraw/types/types"; import { App, MarkdownRenderer, Notice, TFile } from "obsidian"; import { CASCADIA_FONT, fileid, FRONTMATTER_KEY_FONT, FRONTMATTER_KEY_FONTCOLOR, FRONTMATTER_KEY_MD_STYLE, IMAGE_TYPES, nanoid, VIRGIL_FONT, } from "./constants"; import { createSVG } from "./ExcalidrawAutomate"; import { ExcalidrawData, getTransclusion } from "./ExcalidrawData"; import { ExportSettings } from "./ExcalidrawView"; import { t } from "./lang/helpers"; import { tex2dataURL } from "./LaTeX"; import ExcalidrawPlugin from "./main"; import { errorlog, getDataURL, getFontDataURL, getImageSize, getLinkParts, LinkParts, svgToBase64, } from "./Utils"; export declare type MimeType = | "image/svg+xml" | "image/png" | "image/jpeg" | "image/gif" | "application/octet-stream"; export type FileData = BinaryFileData & { size: Size; hasSVGwithBitmap: boolean; }; export type Size = { height: number; width: number; }; export class EmbeddedFile { public file: TFile = null; public isSVGwithBitmap: boolean = false; private img: string = ""; //base64 private imgInverted: string = ""; //base64 public mtime: number = 0; //modified time of the image private plugin: ExcalidrawPlugin; public mimeType: MimeType = "application/octet-stream"; public size: Size = { height: 0, width: 0 }; public linkParts: LinkParts; /*public isHyperlink: boolean = false;*/ constructor(plugin: ExcalidrawPlugin, hostPath: string, imgPath: string) { this.plugin = plugin; this.resetImage(hostPath, imgPath); } public resetImage(hostPath: string, imgPath: string) { /*if(imgPath.startsWith("https://") || imgPath.startsWith("http://")) { this.img=imgPath; this.imgInverted=imgPath; this.isHyperlink = true; return; }*/ this.imgInverted = this.img = ""; this.mtime = 0; this.linkParts = getLinkParts(imgPath); if (!this.linkParts.path) { new Notice(`Excalidraw Error\nIncorrect embedded filename: ${imgPath}`); return; } if (!this.linkParts.width) { this.linkParts.width = this.plugin.settings.mdSVGwidth; } if (!this.linkParts.height) { this.linkParts.height = this.plugin.settings.mdSVGmaxHeight; } this.file = this.plugin.app.metadataCache.getFirstLinkpathDest( this.linkParts.path, hostPath, ); if (!this.file) { new Notice( `Excalidraw Warning: could not find image file: ${imgPath}`, 5000, ); } } private fileChanged(): boolean { if (!this.file) { return false; } return this.mtime != this.file.stat.mtime; } public setImage( imgBase64: string, mimeType: MimeType, size: Size, isDark: boolean, isSVGwithBitmap: boolean, ) { if (!this.file) { return; } if (this.fileChanged()) { this.imgInverted = this.img = ""; } this.mtime = this.file.stat.mtime; this.size = size; this.mimeType = mimeType; switch (isDark && isSVGwithBitmap) { case true: this.imgInverted = imgBase64; break; case false: this.img = imgBase64; break; //bitmaps and SVGs without an embedded bitmap do not need a negative image } this.isSVGwithBitmap = isSVGwithBitmap; } public isLoaded(isDark: boolean): boolean { if (!this.file) { return true; } if (this.fileChanged()) { return false; } if (this.isSVGwithBitmap && isDark) { return this.imgInverted !== ""; } return this.img !== ""; } public getImage(isDark: boolean) { /*if(this.isHyperlink) { return this.img; }*/ if (!this.file) { return ""; } if (isDark && this.isSVGwithBitmap) { return this.imgInverted; } return this.img; //images that are not SVGwithBitmap, only the light string is stored, since inverted and non-inverted are === } } export class EmbeddedFilesLoader { private plugin: ExcalidrawPlugin; private processedFiles: Map = new Map(); private isDark: boolean; public terminate = false; public uid: string; constructor(plugin: ExcalidrawPlugin, isDark?: boolean) { this.plugin = plugin; this.isDark = isDark; this.uid = nanoid(); } public async getObsidianImage(inFile: TFile | EmbeddedFile): Promise<{ mimeType: MimeType; fileId: FileId; dataURL: DataURL; created: number; hasSVGwithBitmap: boolean; size: { height: number; width: number }; }> { if (!this.plugin || !inFile) { return null; } const file: TFile = inFile instanceof EmbeddedFile ? inFile.file : inFile; const linkParts = inFile instanceof EmbeddedFile ? inFile.linkParts : { original: file.path, path: file.path, isBlockRef: false, ref: null, width: this.plugin.settings.mdSVGwidth, height: this.plugin.settings.mdSVGmaxHeight, }; //to block infinite loop of recursive loading of images const count = this.processedFiles.has(file.path) ? this.processedFiles.get(file.path) : 0; if (file.extension === "md" && count > 2) { new Notice(t("INFINITE_LOOP_WARNING") + file.path, 6000); return null; } this.processedFiles.set(file.path, count + 1); let hasSVGwithBitmap = false; const app = this.plugin.app; const isExcalidrawFile = this.plugin.isExcalidrawFile(file); if ( !( IMAGE_TYPES.contains(file.extension) || isExcalidrawFile || file.extension === "md" ) ) { return null; } const ab = await app.vault.readBinary(file); const getExcalidrawSVG = async (isDark: boolean) => { //debug({where:"EmbeddedFileLoader.getExcalidrawSVG",uid:this.uid,file:file.name}); const exportSettings: ExportSettings = { withBackground: false, withTheme: false, }; const svg = await createSVG( file.path, true, exportSettings, this, null, null, null, [], this.plugin, ); //https://stackoverflow.com/questions/51154171/remove-css-filter-on-child-elements const imageList = svg.querySelectorAll( "image:not([href^='data:image/svg'])", ); if (imageList.length > 0) { hasSVGwithBitmap = true; } if (hasSVGwithBitmap && isDark) { const THEME_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)"; imageList.forEach((i) => { const id = i.parentElement?.id; svg.querySelectorAll(`use[href='#${id}']`).forEach((u) => { u.setAttribute("filter", THEME_FILTER); }); }); } if (!hasSVGwithBitmap && svg.getAttribute("hasbitmap")) { hasSVGwithBitmap = true; } const dURL = svgToBase64(svg.outerHTML) as DataURL; return dURL as DataURL; }; const excalidrawSVG = isExcalidrawFile ? await getExcalidrawSVG(this.isDark) : null; let mimeType: MimeType = "image/svg+xml"; if (!isExcalidrawFile) { switch (file.extension) { case "png": mimeType = "image/png"; break; case "jpeg": mimeType = "image/jpeg"; break; case "jpg": mimeType = "image/jpeg"; break; case "gif": mimeType = "image/gif"; break; case "svg": case "md": mimeType = "image/svg+xml"; break; default: mimeType = "application/octet-stream"; } } const dataURL = excalidrawSVG ?? (file.extension === "svg" ? await getSVGData(app, file) : file.extension === "md" ? await convertMarkdownToSVG(this.plugin, file, linkParts) : await getDataURL(ab, mimeType)); const size = await getImageSize( excalidrawSVG ?? (file.extension === "md" ? dataURL : app.vault.getResourcePath(file)), ); return { mimeType, fileId: await generateIdFromFile(ab), dataURL, created: file.stat.mtime, hasSVGwithBitmap, size, }; } public async loadSceneFiles( excalidrawData: ExcalidrawData, addFiles: Function, ) { const entries = excalidrawData.getFileEntries(); //debug({where:"EmbeddedFileLoader.loadSceneFiles",uid:this.uid,isDark:this.isDark,sceneTheme:excalidrawData.scene.appState.theme}); if (this.isDark === undefined) { this.isDark = excalidrawData.scene.appState.theme === "dark"; } let entry; const files: FileData[] = []; while (!this.terminate && !(entry = entries.next()).done) { const embeddedFile: EmbeddedFile = entry.value[1]; if (!embeddedFile.isLoaded(this.isDark)) { //debug({where:"EmbeddedFileLoader.loadSceneFiles",uid:this.uid,status:"embedded Files are not loaded"}); const data = await this.getObsidianImage(embeddedFile); if (data) { files.push({ mimeType: data.mimeType, id: entry.value[0], dataURL: data.dataURL, created: data.created, size: data.size, hasSVGwithBitmap: data.hasSVGwithBitmap, }); } } else if (embeddedFile.isSVGwithBitmap) { files.push({ mimeType: embeddedFile.mimeType, id: entry.value[0], dataURL: embeddedFile.getImage(this.isDark) as DataURL, created: embeddedFile.mtime, size: embeddedFile.size, hasSVGwithBitmap: embeddedFile.isSVGwithBitmap, }); } } let equation; const equations = excalidrawData.getEquationEntries(); while (!this.terminate && !(equation = equations.next()).done) { if (!excalidrawData.getEquation(equation.value[0]).isLoaded) { const latex = equation.value[1].latex; const data = await tex2dataURL(latex, this.plugin); if (data) { files.push({ mimeType: data.mimeType, id: equation.value[0], dataURL: data.dataURL, created: data.created, size: data.size, hasSVGwithBitmap: false, }); } } } if (this.terminate) { return; } //debug({where:"EmbeddedFileLoader.loadSceneFiles",uid:this.uid,status:"add Files"}); try { //in try block because by the time files are loaded the user may have closed the view addFiles(files, this.isDark); } catch (e) { errorlog({ where: "EmbeddedFileLoader.loadSceneFiles", error: e }); } } } const getSVGData = async (app: App, file: TFile): Promise => { const svg = await app.vault.read(file); return svgToBase64(svg) as DataURL; }; const convertMarkdownToSVG = async ( plugin: ExcalidrawPlugin, file: TFile, linkParts: LinkParts, ): Promise => { //1. //get the markdown text let text = (await getTransclusion(linkParts, plugin.app, file)).contents; if (text === "") { text = "# Empty markdown file\nCTRL+Click here to open the file for editing in the current active pane, or CTRL+SHIFT+Click to open it in an adjacent pane."; } //2. //get styles const fileCache = plugin.app.metadataCache.getFileCache(file); let fontDef: string; let fontName = plugin.settings.mdFont; if ( fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEY_FONT] != null ) { fontName = fileCache.frontmatter[FRONTMATTER_KEY_FONT]; } switch (fontName) { case "Virgil": fontDef = VIRGIL_FONT; break; case "Cascadia": fontDef = CASCADIA_FONT; break; case "": fontDef = ""; break; default: const font = await getFontDataURL(plugin.app, fontName, file.path); fontDef = font.fontDef; fontName = font.fontName; } const fontColor = fileCache?.frontmatter ? fileCache.frontmatter[FRONTMATTER_KEY_FONTCOLOR] ?? plugin.settings.mdFontColor : plugin.settings.mdFontColor; let style = fileCache?.frontmatter ? fileCache.frontmatter[FRONTMATTER_KEY_MD_STYLE] ?? "" : ""; let frontmatterCSSisAfile = false; if (style && style != "") { const f = plugin.app.metadataCache.getFirstLinkpathDest(style, file.path); if (f) { style = await plugin.app.vault.read(f); frontmatterCSSisAfile = true; } } if ( !frontmatterCSSisAfile && plugin.settings.mdCSS && plugin.settings.mdCSS != "" ) { const f = plugin.app.metadataCache.getFirstLinkpathDest( plugin.settings.mdCSS, file.path, ); if (f) { style += `\n${await plugin.app.vault.read(f)}`; } } //3. //SVG helper functions //the SVG will first have ~infinite height. After sizing this will be reduced let svgStyle = ` width="${linkParts.width}px" height="100000"`; let foreignObjectStyle = ` width="${linkParts.width}px" height="100%"`; const svg = (xml: string, xmlFooter: string, style?: string) => `${ style ? `` : "" }${xml}${ xmlFooter //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/286#issuecomment-982179639 }${ fontDef !== "" ? `` : "" }`; //4. //create document div - this will be the contents of the foreign object const mdDIV = createDiv(); mdDIV.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); mdDIV.setAttribute("class", "excalidraw-md-host"); // mdDIV.setAttribute("style",style); if (fontName !== "") { mdDIV.style.fontFamily = fontName; } mdDIV.style.overflow = "auto"; mdDIV.style.display = "block"; if (fontColor && fontColor != "") { mdDIV.style.color = fontColor; } await MarkdownRenderer.renderMarkdown(text, mdDIV, file.path, plugin); mdDIV .querySelectorAll(":scope > *[class^='frontmatter']") .forEach((el) => mdDIV.removeChild(el)); //5.1 //get SVG size. //First I need to create a fully self contained copy of the document to convert //blank styles into inline styles using computedStyle const iframeHost = document.body.createDiv(); iframeHost.style.display = "none"; const iframe = iframeHost.createEl("iframe"); const iframeDoc = iframe.contentWindow.document; if (style) { const styleEl = iframeDoc.createElement("style"); styleEl.type = "text/css"; styleEl.innerHTML = style; iframeDoc.head.appendChild(styleEl); } const stylingDIV = iframeDoc.importNode(mdDIV, true); iframeDoc.body.appendChild(stylingDIV); const footerDIV = createDiv(); footerDIV.setAttribute("class", "excalidraw-md-footer"); iframeDoc.body.appendChild(footerDIV); iframeDoc.body.querySelectorAll("*").forEach((el: HTMLElement) => { const elementStyle = el.style; const computedStyle = window.getComputedStyle(el); let style = ""; for (const prop in elementStyle) { if (elementStyle.hasOwnProperty(prop)) { style += `${prop}: ${computedStyle[prop]};`; } } el.setAttribute("style", style); }); const xmlINiframe = new XMLSerializer().serializeToString(stylingDIV); const xmlFooter = new XMLSerializer().serializeToString(footerDIV); document.body.removeChild(iframeHost); //5.2 //get SVG size const parser = new DOMParser(); const doc = parser.parseFromString( svg(xmlINiframe, xmlFooter), "image/svg+xml", ); const svgEl = doc.firstElementChild; const host = createDiv(); host.appendChild(svgEl); document.body.appendChild(host); const footerHeight = svgEl.querySelector( ".excalidraw-md-footer", ).scrollHeight; const height = svgEl.querySelector(".excalidraw-md-host").scrollHeight + footerHeight; const svgHeight = height <= linkParts.height ? height : linkParts.height; document.body.removeChild(host); //finalize SVG svgStyle = ` width="${linkParts.width}px" height="${svgHeight}px"`; foreignObjectStyle = ` width="${linkParts.width}px" height="${svgHeight}px"`; mdDIV.style.height = `${svgHeight - footerHeight}px`; mdDIV.style.overflow = "hidden"; const xml = new XMLSerializer().serializeToString(mdDIV); const finalSVG = svg(xml, '', style); plugin.ea.mostRecentMarkdownSVG = parser.parseFromString( finalSVG, "image/svg+xml", ).firstElementChild as SVGSVGElement; return svgToBase64(finalSVG) as DataURL; }; const generateIdFromFile = async (file: ArrayBuffer): Promise => { let id: FileId; try { const hashBuffer = await window.crypto.subtle.digest("SHA-1", file); id = // convert buffer to byte array Array.from(new Uint8Array(hashBuffer)) // convert to hex string .map((byte) => byte.toString(16).padStart(2, "0")) .join("") as FileId; } catch (error) { errorlog({ where: "EmbeddedFileLoader.generateIdFromFile", error }); id = fileid() as FileId; } return id; };