Obsidian 自动同步(4)- Excalidraw 转换

前言

本篇是 Obsidian 文章自动同步 Hexo 仓库系列的最后一篇。之前的实现中,我们完成了常规格式图片的迁移,但尚未处理 Excalidraw 插件生成的 Markdown 格式文件。本篇将讲解这类文件的处理方法及核心代码,以解析和动态渲染 Excalidraw 插件内容为主要目标。

由于前置内容依赖比较多,代码质量也相对较差,所以本篇主要讲解处理的思路和核心代码,不会提供这部分完整代码。

Excalidraw

Excalidraw 是一个开源的绘图工具,专注于手绘风格的草图创作,适合快速构思和可视化表达。它提供了简单直观的界面,支持基本图形、连接线、文字等元素,广泛应用于思维导图、流程图、界面草图等领域。此外,Excalidraw 还能通过插件嵌入到 Obsidian 等笔记软件中,使用户可以在 Markdown 文件中直接创建和编辑绘图,从而更高效地管理和呈现图文内容。

Obsidian 自动同步(4)- Excalidraw 转换

简单解决方式

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

Obsidian 自动同步(4)- Excalidraw 转换

代码迁移思路

原因

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

这是一个 Excalidraw 绘图文件中的内容,有两大块元素。

Obsidian 自动同步(4)- Excalidraw 转换

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

Obsidian 自动同步(4)- Excalidraw 转换

当然,Excalidraw 支持多种引用模式,包括 groupareaframeClipped Frame。在实现过程中,我仅对 groupframe 两种类型进行了转换,以满足当前需求。

思路

具体的迁移思路如下:我们首先将 Markdown 转换为 AST,并提取其中所有图片的 URL。获取到 URL 后,我们需要解析它,因为若引用了图片的部分内容,URL 后会附带特定参数来标明引用区域。解析出 URL、类型和 ID 后,接下来读取绘图文件。若文件为 Markdown 且包含 Excalidraw 的元数据,我们便从中读取 JSON 数据,将这些数据和渲染代码注入到 Markdown 中,从而在前端成功展示 Excalidraw 图片。

数据示例

下面是阉割版的数据示例,包含了元信息,文本元素、嵌入文件、绘图数据四个主要部分。

Obsidian 自动同步(4)- 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
}

解析 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;
// 解析 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');
// 使用 front-matter 解析元数据
const parsed = fm(content);
// 如果元数据中没有 excalidraw-plugin 标识,则不是 excalidraw 文件
if (!parsed.attributes['excalidraw-plugin']) return;
/**
* .......................
* 处理 excalidraw markdown
* .......................
*/
} catch (error) {
logger.error(`Error processing Excalidraw file at ${fullUrl}: ${error.toString()}`);
}
});

parsedContent.body = remark().use(remarkStringify).stringify(ast);
}

2. 解析 excalidraw 数据

如果确定是 excalidraw 文件之后,我们就可以将正文转成 ast, 然后尝试读取是否存在 json, compressed-json 类型的代码块,前者就是明文 json 数据,后者是 excalidraw 插件压缩之后的数据。

压缩数据示例

下面是一个压缩之后的数据示例

Obsidian 自动同步(4)- Excalidraw 转换

压缩开关

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

Obsidian 自动同步(4)- Excalidraw 转换

处理压缩数据

在配置面板,我们可以看到在说明中已经提到了 Excalidraw 是通过 LZ-String 算法进行压缩的,所以我们需要先通过 npm install lz-string 安装依赖包。

由于 markdown 中有换行符,这并非压缩之后的产物,所以要在使用解压之前将所有的换行符删除,要不然无法解压出数据。

1
2
3
4
5
6
7
8
9
10
11
12
// 处理解析 excalidraw compressed-json 数据
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);
}

处理 Excalidraw 图片数据

在获取到 Excalidraw 数据后,我们便可以对其进行处理。

这里,我们仅实现了 groupframe 两种引用类型的处理,通过分组 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
// 处理 Excalidraw 文件中的图片
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;
}

// 处理 excalidraw 中使用图片
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
}

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');

// 检查是否已加载相同的脚本 URL,避免重复加载
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);

// 兼容旧版浏览器的 readyState 处理
script.onreadystatechange = function () {
if (this.readyState === 'loaded' || this.readyState === 'complete') {
script.onload();
}
};
} else {
// 处理文本脚本
script.textContent = input;
setTimeout(() => onLoadCallback(true), 0);
}

document.body.appendChild(script);
});
}

接下来,我将编写一个方法,用于加载 ReactReactDOMExcalidraw 三个库。我将源码直接放在 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)));
}

注入代码

在解析 Excalidraw 的代码块时,我们将提取的数据存放到一个数组中。同时,将原本的 ![](../xxx.md) 语法替换为 <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
// 如果是 compressed-json 则说明启用压缩了
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);
});

首先,我们需要实现一个生成 Excalidraw 注入脚本的功能。其原理是在 Markdown 中插入 <script> 标签,然后遍历数据,调用 loadExcalidraw 方法。当 ReactReactDOMExcalidraw 三个库加载完成后,我们将通过 ExcalidrawLib.exportToSvg 方法将数据传入。这样,就能获取到相应的 SVG 代码,随后通过 Data URL 的方式将其设置到对应的 <img> 标签上,从而实现图片的渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 生成 Excalidraw 注入脚本
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';
}

在所有当前文件所有的 Excalidraw 处理完成之后,在 AST 尾部追加生成的代码。

1
2
3
4
if (injectionData.length) {
const injectionScript = this.generateExcalidrawScript(injectionData);
ast.children.push({ type: 'html', value: injectionScript });
}

结语

至此,Obsidian 文章同步至 Hexo 博客的系列内容也告一段落。虽然仍有一些问题需要处理,例如文章封面等,这些将作为后续的优化项,暂时可以不予考虑。

通过这次 Obsidian 的迁移过程,我收获颇丰,巩固了不少知识,包括 AST 的应用、Node.js 处理图片的技巧、Data URL 的使用以及 LZ-String 压缩库等。这次经历不仅丰富了我的知识储备,也为以后编写脚本打下了基础。


Obsidian 自动同步(4)- Excalidraw 转换
https://blog.pangcy.cn/2024/10/30/前端编程相关/前端框架与库/hexo/Obsidian 自动同步(4)- Excalidraw 转换/
作者
子洋
发布于
2024年10月30日
许可协议