//https://stackoverflow.com/questions/2068344/how-do-i-get-a-youtube-video-thumbnail-from-the-youtube-api //https://img.youtube.com/vi/uZz5MgzWXiM/maxresdefault.jpg import { FileId } from "@zsviczian/excalidraw/types/element/types"; import { BinaryFileData, DataURL } from "@zsviczian/excalidraw/types/types"; import { App, MarkdownRenderer, Notice, requestUrl, RequestUrlResponse, TFile } from "obsidian"; import { CASCADIA_FONT, DEFAULT_MD_EMBED_CSS, fileid, FRONTMATTER_KEY_BORDERCOLOR, FRONTMATTER_KEY_FONT, FRONTMATTER_KEY_FONTCOLOR, FRONTMATTER_KEY_MD_STYLE, IMAGE_TYPES, nanoid, URLFETCHTIMEOUT, 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, getExportTheme, getFontDataURL, getImageSize, getLinkParts, getExportPadding, getWithBackground, hasExportBackground, hasExportTheme, LinkParts, svgToBase64, } from "./utils/Utils"; const THEME_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)"; export declare type MimeType = | "image/svg+xml" | "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/bmp" | "image/x-icon" | "application/octet-stream"; export type FileData = BinaryFileData & { size: Size; hasSVGwithBitmap: boolean; shouldScale: boolean; //true if image should maintain its area, false if image should display at 100% its size }; 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; private hostPath: string; public attemptCounter: number = 0; public isHyperlink: boolean = false; public hyperlink:DataURL; constructor(plugin: ExcalidrawPlugin, hostPath: string, imgPath: string) { this.plugin = plugin; this.resetImage(hostPath, imgPath); } public resetImage(hostPath: string, imgPath: string) { this.imgInverted = this.img = ""; this.mtime = 0; if(imgPath.startsWith("https://") || imgPath.startsWith("http://")){ this.isHyperlink = true; this.hyperlink = imgPath as DataURL; return; }; this.linkParts = getLinkParts(imgPath); this.hostPath = hostPath; 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 = app.metadataCache.getFirstLinkpathDest( this.linkParts.path, hostPath, ); if (!this.file) { if(this.attemptCounter++ === 0) { new Notice( `Excalidraw Warning: could not find image file: ${imgPath}`, 5000, ); } } } private fileChanged(): boolean { if(this.isHyperlink) { return false; } if (!this.file) { this.file = app.metadataCache.getFirstLinkpathDest( this.linkParts.path, this.hostPath, ); // maybe the file has synchronized in the mean time if(!this.file) { this.attemptCounter++; return false; } } return this.mtime != this.file.stat.mtime; } public setImage( imgBase64: string, mimeType: MimeType, size: Size, isDark: boolean, isSVGwithBitmap: boolean, ) { if (!this.file && !this.isHyperlink) { return; } if (this.fileChanged()) { this.imgInverted = this.img = ""; } this.mtime = this.isHyperlink ? 0 : 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.isHyperlink) { if (!this.file) { this.file = app.metadataCache.getFirstLinkpathDest( this.linkParts.path, this.hostPath, ); // maybe the file has synchronized in the mean time if(!this.file) { this.attemptCounter++; return true; } } if (this.fileChanged()) { return false; } } if (this.isSVGwithBitmap && isDark) { return this.imgInverted !== ""; } return this.img !== ""; } public getImage(isDark: boolean) { if (!this.file && !this.isHyperlink) { 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 === } /** * * @returns true if image should scale such as the updated images has the same area as the previous images, false if the image should be displayed at 100% */ public shouldScale() { return this.isHyperlink || !Boolean(this.linkParts && this.linkParts.original && this.linkParts.original.endsWith("|100%")); } } export class EmbeddedFilesLoader { private plugin: ExcalidrawPlugin; 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, depth: number): Promise<{ mimeType: MimeType; fileId: FileId; dataURL: DataURL; created: number; hasSVGwithBitmap: boolean; size: { height: number; width: number }; }> { if (!this.plugin || !inFile) { return null; } const isHyperlink = inFile instanceof EmbeddedFile ? inFile.isHyperlink : false; const hyperlink = inFile instanceof EmbeddedFile ? inFile.hyperlink : ""; const file: TFile = inFile instanceof EmbeddedFile ? inFile.file : inFile; const linkParts = isHyperlink ? null : 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, }; let hasSVGwithBitmap = false; const isExcalidrawFile = !isHyperlink && this.plugin.isExcalidrawFile(file); if ( !isHyperlink && !( IMAGE_TYPES.contains(file.extension) || isExcalidrawFile || file.extension === "md" ) ) { return null; } const ab = isHyperlink ? null : await app.vault.readBinary(file); const getExcalidrawSVG = async (isDark: boolean) => { //debug({where:"EmbeddedFileLoader.getExcalidrawSVG",uid:this.uid,file:file.name}); const forceTheme = hasExportTheme(this.plugin, file) ? getExportTheme(this.plugin, file, "light") : undefined; const exportSettings: ExportSettings = { withBackground: hasExportBackground(this.plugin, file) ? getWithBackground(this.plugin, file) : false, withTheme: !!forceTheme, }; const svg = await createSVG( file.path, true, exportSettings, this, forceTheme, null, null, [], this.plugin, depth+1, getExportPadding(this.plugin, file), ); //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) { 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"; const corelink = isHyperlink ? hyperlink.split("?")[0] : ""; const extension = isHyperlink ? corelink.substring(corelink.lastIndexOf(".")+1) : file.extension; if (!isExcalidrawFile) { switch (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 "webp": mimeType = "image/webp"; break; case "bmp": mimeType = "image/bmp"; break; case "ico": mimeType = "image/x-icon" break; case "svg": case "md": mimeType = "image/svg+xml"; break; default: mimeType = "application/octet-stream"; } } let response:RequestUrlResponse; if(isHyperlink && inFile instanceof EmbeddedFile) { try { response = await Promise.race([ (async () => new Promise((resolve) => setTimeout(()=>resolve(null), URLFETCHTIMEOUT)))(), requestUrl({url: inFile.hyperlink, method: "get", contentType: mimeType, throw: false }) ]) } catch (e) { errorlog({where: this.getObsidianImage, message: `URL did not load within timeout period of ${URLFETCHTIMEOUT}ms`, url: inFile.hyperlink}); } } let dataURL = isHyperlink ? ( inFile instanceof EmbeddedFile ? (response && response.status === 200 ? await getDataURL(response.arrayBuffer, mimeType) : inFile.hyperlink) : null ) : excalidrawSVG ?? (file.extension === "svg" ? await getSVGData(app, file) : file.extension === "md" ? null : await getDataURL(ab, mimeType)); if(!isHyperlink && !dataURL) { const result = await this.convertMarkdownToSVG(this.plugin, file, linkParts); dataURL = result.dataURL; hasSVGwithBitmap = result.hasSVGwithBitmap; } try{ const size = await getImageSize(dataURL); return { mimeType, fileId: await generateIdFromFile( isHyperlink? (new TextEncoder()).encode(dataURL as string) : ab ), dataURL, created: isHyperlink ? 0 : file.stat.mtime, hasSVGwithBitmap, size, }; } catch(e) { return null; } } public async loadSceneFiles( excalidrawData: ExcalidrawData, addFiles: (files: FileData[], isDark: boolean) => void, depth:number ) { if(depth > 4) { new Notice(t("INFINITE_LOOP_WARNING")+depth.toString(), 6000); return; } 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, depth); if (data) { files.push({ mimeType: data.mimeType, id: entry.value[0], dataURL: data.dataURL, created: data.created, size: data.size, hasSVGwithBitmap: data.hasSVGwithBitmap, shouldScale: embeddedFile.shouldScale() }); } } 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, shouldScale: embeddedFile.shouldScale() }); } } 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, shouldScale: true }); } } } 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 }); } } private async convertMarkdownToSVG( plugin: ExcalidrawPlugin, file: TFile, linkParts: LinkParts, ): Promise<{dataURL: DataURL, hasSVGwithBitmap:boolean}> { //1. //get the markdown text let hasSVGwithBitmap = false; const transclusion = await getTransclusion(linkParts, plugin.app, file); let text = (transclusion.leadingHashes??"") + transclusion.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 && Boolean(fileCache.frontmatter[FRONTMATTER_KEY_FONT]) ) { 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; } if ( fileCache?.frontmatter && fileCache.frontmatter["banner"] !== null ) { text = text.replace(/banner:\s*.*/,""); //patch https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/814 } 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) { if (plugin.settings.mdCSS && plugin.settings.mdCSS !== "") { const f = plugin.app.metadataCache.getFirstLinkpathDest( plugin.settings.mdCSS, file.path, ); style += f ? `\n${await plugin.app.vault.read(f)}` : DEFAULT_MD_EMBED_CSS; } else { style += DEFAULT_MD_EMBED_CSS; } } const borderColor = fileCache?.frontmatter ? fileCache.frontmatter[FRONTMATTER_KEY_BORDERCOLOR] ?? plugin.settings.mdBorderColor : plugin.settings.mdBorderColor; if (borderColor && borderColor !== "" && !style.match(/svg/i)) { style += `svg{border:2px solid;color:${borderColor};transform:scale(.95)}`; } //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"; mdDIV.style.color = fontColor && fontColor !== "" ? fontColor : "initial"; await MarkdownRenderer.renderMarkdown(text, mdDIV, file.path, plugin); mdDIV .querySelectorAll(":scope > *[class^='frontmatter']") .forEach((el) => mdDIV.removeChild(el)); const internalEmbeds = Array.from(mdDIV.querySelectorAll("span[class='internal-embed']")) for(let i=0;i { 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 imageList = mdDIV.querySelectorAll( "img:not([src^='data:image/svg+xml'])", ); if (imageList.length > 0) { hasSVGwithBitmap = true; } if (hasSVGwithBitmap && this.isDark) { imageList.forEach(img => { if(img instanceof HTMLImageElement) { img.style.filter = THEME_FILTER; } }); } const xml = new XMLSerializer().serializeToString(mdDIV); const finalSVG = svg(xml, '', style); plugin.ea.mostRecentMarkdownSVG = parser.parseFromString( finalSVG, "image/svg+xml", ).firstElementChild as SVGSVGElement; return { dataURL: svgToBase64(finalSVG) as DataURL, hasSVGwithBitmap }; }; } const getSVGData = async (app: App, file: TFile): Promise => { const svg = await app.vault.read(file); return svgToBase64(svg) 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; };