前言
本篇是 Obsidian 文章自动同步 Hexo 仓库系列的最后一篇。之前的实现中,我们完成了常规格式图片的迁移,但尚未处理 Excalidraw 插件生成的 Markdown 格式文件。本篇将讲解这类文件的处理方法及核心代码,以解析和动态渲染 Excalidraw 插件内容为主要目标。
由于前置内容依赖比较多,代码质量也相对较差,所以本篇主要讲解处理的思路和核心代码,不会提供这部分完整代码。
Excalidraw
Excalidraw 是一个开源的绘图工具,专注于手绘风格的草图创作,适合快速构思和可视化表达。它提供了简单直观的界面,支持基本图形、连接线、文字等元素,广泛应用于思维导图、流程图、界面草图等领域。此外,Excalidraw 还能通过插件嵌入到 Obsidian 等笔记软件中,使用户可以在 Markdown 文件中直接创建和编辑绘图,从而更高效地管理和呈现图文内容。

简单解决方式
有时候,解决问题不一定需要复杂的方法,Excalidraw 提供了一个简单的解决方案,在插件设置中找到 【嵌入到 Markdlown 文档中的绘图】 -> 【导出】 -> 【导出设置】 中,我们可以开启【自动导出 SVG / PNG 副本】(通常选择其中一种格式即可),并确保勾选“保持 SVG/PNG 文件名与绘图文件同步”。这样,每次保存 Excalidraw 文件时,系统会自动在同目录下生成一个同名的 SVG 或 PNG 文件。我们可以直接在正文中引用这个同名文件,无需额外处理,实现了图文同步。

代码迁移思路
原因
虽然上述方法几乎可以完美解决问题,但仍存在一些小瑕疵。例如,每个文件都会生成一个同名的副本,随着数量增加可能导致笔记仓库体积变大。此外,直接导出的图片无法支持块级元素引用(这是一个新特性,允许在 Markdown 中通过 groupId
引用图片的特定部分)。这些限制在某些场景下可能会带来不便。
这是一个 Excalidraw 绘图文件中的内容,有两大块元素。

下图是通过 ID 分别在两个位置引用了一个绘图文件中的两个元素

当然,Excalidraw 支持多种引用模式,包括 group
、area
、frame
和 Clipped Frame
。在实现过程中,我仅对 group
和 frame
两种类型进行了转换,以满足当前需求。
思路
具体的迁移思路如下:我们首先将 Markdown 转换为 AST,并提取其中所有图片的 URL。获取到 URL 后,我们需要解析它,因为若引用了图片的部分内容,URL 后会附带特定参数来标明引用区域。解析出 URL、类型和 ID 后,接下来读取绘图文件。若文件为 Markdown 且包含 Excalidraw 的元数据,我们便从中读取 JSON 数据,将这些数据和渲染代码注入到 Markdown 中,从而在前端成功展示 Excalidraw 图片。
数据示例
下面是阉割版的数据示例,包含了元信息,文本元素、嵌入文件、绘图数据四个主要部分。

代码实现
1. 查找 excalidraw 绘图文件
这一步比较简单,基于之前文章中处理的前提下,我们现在可以通过 AST 直接获取文章中的所有图片链接,接下来需要解析这些链接。
Excalidraw 在 Url 通常有以下四种
- 单纯的 markdown 链接,此时渲染所有内容
./20230718.excalidraw.md
,
|
后面是别名,这个我们可以不予理会
./20230718.excalidraw.md|xxxxxxxx
#^group
是代表分组引用,等号后面是 ID, 我们可以根据这个 ID 找到关联的绘图元素
./20230718.excalidraw.md#^group=S_rSRdDbTouA7vHS3EPxO
- 这个就是包含分组引用、别名的完整链接了。
./20230718.excalidraw.md#^group=S_rSRdDbTouA7vHS3EPxO|testxxx
我们首先需要写一个正则匹配出,url , type (上面提到了,引用类型有好几种),引用元素的 id, 还有别名。
1 2 3 4 5 6 7
| parsingImageUrl(url) { if (/^https?:\/\/.*/.test(url)) return const pattern = /^(?<url>.+?)(?:#\^(?<type>[^=]+)=(?<id>[^|]+)(?:\|(?<alias1>[^ ]+))?|\|(?<alias2>[^ ]+))?$/u; const matches = url.match(pattern) if (!matches.groups.url?.endsWith?.('.md')) return; return matches.groups }
JS
|
解析 excalidraw 文件
我们通过 parsingImageUrl
拿到解析后的 url 之后,就可以拼接出完整的路径了,然后拿到文件在通过 front-matter
解析出元数据,判断是否存在 excalidraw-plugin
配置,如果存在则说明是 excalidraw 文件,反之则不是。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| convertExcalidrawFile(parsedContent, filePath) { const ast = remark().use(remarkParse).parse(parsedContent.body); const injectionData = []; let idNum = 1; visit(ast, 'image', (node, index, parent) => { let url = node.url; const conf = this.parsingImageUrl(url); if (!conf) return const fullUrl = decodeURI(resolve(filePath.replace(basename(filePath), ''), conf.url)); try { const content = readFileSync(fullUrl, 'utf-8'); const parsed = fm(content); if (!parsed.attributes['excalidraw-plugin']) return;
} catch (error) { logger.error(`Error processing Excalidraw file at ${fullUrl}: ${error.toString()}`); } });
parsedContent.body = remark().use(remarkStringify).stringify(ast); }
JS
|
2. 解析 excalidraw 数据
如果确定是 excalidraw 文件之后,我们就可以将正文转成 ast, 然后尝试读取是否存在 json
, compressed-json
类型的代码块,前者就是明文 json 数据,后者是 excalidraw 插件压缩之后的数据。
压缩数据示例
下面是一个压缩之后的数据示例

压缩开关
在 Excalidraw 插件配置的【保存】面板中,可以启用、禁用压缩数据功能。

处理压缩数据
在配置面板,我们可以看到在说明中已经提到了 Excalidraw 是通过 LZ-String 算法进行压缩的,所以我们需要先通过 npm install lz-string
安装依赖包。
由于 markdown 中有换行符,这并非压缩之后的产物,所以要在使用解压之前将所有的换行符删除,要不然无法解压出数据。
1 2 3 4 5 6 7 8 9 10 11 12
| decompressFromBase64(data) { let cleanedData = ''; const length = data.length; for (let i = 0; i < length; i++) { const char = data[i]; if (char !== '\n' && char !== '\r') { cleanedData += char; } } return LZString.decompressFromBase64(cleanedData); }
JS
|
处理 Excalidraw 图片数据
在获取到 Excalidraw 数据后,我们便可以对其进行处理。
这里,我们仅实现了 group
和 frame
两种引用类型的处理,通过分组 ID 或 frameId
匹配出对应的绘图数据。
由于 Excalidraw 插件中的图片是以附件形式存储在本地,要在网页中显示这些图片,还需要读取相应文件,将其转换为 base64 格式并存放在 files
对象中。
具体来说,可以根据 fileId
在 Embedded Files 下找到图片路径,再与当前文件路径拼接,读取文件内容后进行编码,最终实现图片的顺利加载。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| handleExcalidrawImages(data, content, filePath, conf) { let elements = data.elements;
if (['group', 'frame'].includes(conf.type)) { const findItem = elements.find(({ id }) => id === conf.id) let newElements = [] data.elements.forEach((item) => { if (conf.type === 'group') { if (item.groupIds.some((gId) => findItem.groupIds.includes(gId))) { newElements.push(item) } } else if (conf.type === 'frame') { if (item.frameId === findItem.id) { newElements.push(item) } } }) elements = newElements; }
elements.forEach((ite) => { if (!ite.fileId) return; if (!data.files) data.files = {}; if (data.files[ite.fileId]) return; const imagePath = this.findFileUrl(content, ite.fileId); if (!imagePath) return; const imgPath = this.fullPath(filePath, imagePath); const binary = readFileSync(imgPath); const stat = statSync(imgPath); const mimeType = `image/${extname(imgPath).replace('.', '')}`; data.files[ite.fileId] = { mimeType, id: ite.fileId, dataURL: `data:${mimeType};base64,` + binary.toString('base64'), created: stat.birthtime.getTime(), lastRetrieved: stat.mtime.getTime(), }; }); return elements }
JS
|
3. 生成 excalidraw 渲染代码
由于 Excalidraw 库依赖于浏览器环境,而当前的 Git hook 同步脚本想要完全模拟浏览器环境,需要使用无头浏览器来处理,这在实际操作中显得不太合理。因此,我决定将 Excalidraw 库的相关依赖直接引入博客中,并将数据注入到博客页面中。这样,当用户打开这篇文章时,就会加载所需的依赖环境,同时利用 Excalidraw 库获取导出的 SVG,从而在页面上成功呈现图片。
配置 hexo
在 /source/js/
下面创建一个 loadScript.js
文件,然后我们实现一个异步加载 js 文件的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| function loadScript(input, {async = true} = {}) { return new Promise((resolve, reject) => { const isUrl = input.startsWith('http') || input.endsWith('.js'); if (isUrl && document.querySelector(`script[src="${input}"]`)) { console.warn(`Script with URL '${input}' is already loaded.`); return resolve({success: true, type: 'url', input}); } const script = document.createElement('script'); script.type = 'text/javascript'; script.async = async; const onLoadCallback = (success) => { if (success) { resolve({success: true, type: isUrl ? 'url' : 'text', input}); } else { reject({success: false, type: isUrl ? 'url' : 'text', input}); } }; if (isUrl) { script.src = input; script.onload = () => onLoadCallback(true); script.onerror = () => onLoadCallback(false); script.onreadystatechange = function () { if (this.readyState === 'loaded' || this.readyState === 'complete') { script.onload(); } }; } else { script.textContent = input; setTimeout(() => onLoadCallback(true), 0); } document.body.appendChild(script); }); }
JS
|
接下来,我将编写一个方法,用于加载 React
、ReactDOM
和 Excalidraw
三个库。我将源码直接放在 source/js
目录下,当然也可以选择使用 CDN。为了确保在执行后续代码之前这三个库都加载完成,我将通过 Promise.all
返回一个 Promise,以实现并行加载。这种方式可以有效地管理库的依赖,确保所有必要的资源在使用前已准备就绪。
1 2 3 4
| function loadExcalidraw(options = {}) { const scripts = ['/js/react.production.min.js', '/js/react-dom.production.min.js', '/js/excalidraw.production.min.js'] return Promise.all(scripts.map(script => loadScript(script, options))); }
JS
|
注入代码
在解析 Excalidraw 的代码块时,我们将提取的数据存放到一个数组中。同时,将原本的 
语法替换为 <img id="xxx">
的格式,并记录下这个 ID 和相应的数据。这样,在遍历数组时,我们可以根据 ID 设置对应的图片,从而实现灵活的图像渲染。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const excalidrawAst = remark().use(remarkParse).parse(parsedContent.body); visit(excalidrawAst, 'code', (node) => { if (!['compressed-json', 'json'].includes(node.lang)) return if (node.lang === 'compressed-json') { node.value = this.decompressFromBase64(node.value); } const data = JSON.parse(node.value); if (data.type !== 'excalidraw') return; const uniqueId = 'Blog' + (idNum++); data.elements = this.handleExcalidrawImages(data, content, fullUrl, conf); injectionData.push({ id: uniqueId, data: JSON.stringify(data) }); const newNodes = [{ type: 'html', value: `<img id="${uniqueId}" width="100%" alt="img"/>\n` }]; parent.children.splice(index, 1, ...newNodes); });
JS
|
首先,我们需要实现一个生成 Excalidraw 注入脚本的功能。其原理是在 Markdown 中插入 <script>
标签,然后遍历数据,调用 loadExcalidraw
方法。当 React
、ReactDOM
和 Excalidraw
三个库加载完成后,我们将通过 ExcalidrawLib.exportToSvg
方法将数据传入。这样,就能获取到相应的 SVG 代码,随后通过 Data URL 的方式将其设置到对应的 <img>
标签上,从而实现图片的渲染。
1 2 3 4 5 6 7 8 9 10 11 12 13
| generateExcalidrawScript(injectContent) { return '<script type="text/javascript">\n' + ' loadExcalidraw().then(() => {\n' + injectContent.map(({ id, data }) => { return ` const initDataBy${id} = ${data}\n` + ` ExcalidrawLib.exportToSvg(initDataBy${id}).then(svg => {\n` + ` document.getElementById('${id}').src = 'data:image/svg+xml;utf8,' + encodeURIComponent(svg.outerHTML)\n` + ` })\n`; }).join('\n') + ' })\n' + '</script>\n'; }
JS
|
在所有当前文件所有的 Excalidraw 处理完成之后,在 AST 尾部追加生成的代码。
1 2 3 4
| if (injectionData.length) { const injectionScript = this.generateExcalidrawScript(injectionData); ast.children.push({ type: 'html', value: injectionScript }); }
JS
|
结语
至此,Obsidian 文章同步至 Hexo 博客的系列内容也告一段落。虽然仍有一些问题需要处理,例如文章封面等,这些将作为后续的优化项,暂时可以不予考虑。
通过这次 Obsidian 的迁移过程,我收获颇丰,巩固了不少知识,包括 AST 的应用、Node.js 处理图片的技巧、Data URL 的使用以及 LZ-String 压缩库等。这次经历不仅丰富了我的知识储备,也为以后编写脚本打下了基础。