Files
odox/notes/render-canvas.md
nan 41db190fee
Some checks failed
Node.js build / build (20.x) (push) Failing after 18m32s
Node.js build / build (22.x) (push) Has been cancelled
feat: Implement Canvas rendering system and VitePress exporter plugin
- Add Canvas rendering implementation in `notes/render-canvas.md`
- Create VitePress exporter plugin in `src/main.ts` with commands for exporting notes
- Implement settings management for the plugin in `src/settings.ts`
- Add CSS file for potential styling needs
- Create version bump script in `version-bump.mjs` for managing versioning
- Initialize version tracking in `versions.json`
2026-04-20 22:23:23 +08:00

12 KiB
Raw Blame History

Canvas 渲染系统实现

技术栈

  • 渲染方式: Obsidian Canvas 内部 API (takeScreenshot)
  • 缩放控制: canvas.zoom 属性(对数刻度)
  • 语言: TypeScript

核心实现

1. Canvas 捕获流程

async renderCanvas(canvasFile: TFile): Promise<Uint8Array | null> {
    // 1. 获取活动 leaf
    const activeLeaf = this.app.workspace.getLeaf();
    if (!activeLeaf) return null;

    // 2. 打开 Canvas 文件
    await activeLeaf.openFile(canvasFile);

    // 3. 获取 Canvas 对象
    const canvas = activeLeaf.view.canvas;
    if (!canvas) return null;

    // 4. 使用内部 API 导出
    return await this.exportCanvasWithInternalAPI(canvas, canvasFile);
}

2. 使用 Canvas 内部 API

async exportCanvasWithInternalAPI(
    canvas: any,
    canvasFile: TFile
): Promise<Uint8Array | null> {
    try {
        // 1. 获取边界框
        const bbox = canvas.getViewportBBox();
        const contentWidth = bbox.maxX - bbox.minX;
        const contentHeight = bbox.maxY - bbox.minY;

        // 2. 设置 zoom = 0 (100% 缩放)
        const oldZoom = canvas.zoom;
        canvas.zoom = 0;  // 对数刻度:2^0 = 1.0 (100%)

        // 3. 请求重绘
        canvas.requestFrame();
        await new Promise(resolve => setTimeout(resolve, 100));
        await new Promise(resolve => requestAnimationFrame(resolve));

        // 4. 生成文件路径
        const tempFileName = `temp_canvas_${Date.now()}.png`;
        const finalFileName = canvasFile.name.replace(".canvas", ".png");
        const finalPath = `${this.settings.assetsPath}/${finalFileName}`;

        // 5. 获取 vault 绝对路径
        const vaultBasePath = vault.adapter.basePath;
        const tempFullPath = `${vaultBasePath}/${tempFileName}`;

        // 6. 调用 takeScreenshot(需要绝对路径)
        await canvas.takeScreenshot(tempFullPath, false);

        // 7. 等待文件写入完成
        await new Promise(resolve => setTimeout(resolve, 100));

        // 8. 读取临时文件(使用相对路径)
        const imageBuffer = await vault.adapter.readBinary(tempFileName);

        if (!imageBuffer || imageBuffer.byteLength === 0) {
            console.error("Failed to read temporary file");
            return null;
        }

        // 9. 删除临时文件
        await vault.adapter.remove(tempFileName);

        // 10. 写入到最终位置
        await vault.adapter.writeBinary(finalPath, imageBuffer);

        // 11. 返回 Uint8Array
        return new Uint8Array(imageBuffer);

    } catch (error) {
        console.error("exportCanvasWithInternalAPI failed:", error);
        return null;
    }
}

3. Canvas Zoom 对数刻度

关键发现:canvas.zoom 使用对数刻度(base 2)

zoom 值 缩放比例 说明
0 100% (2^0) 原始尺寸,我们要设置的值
1 200% (2^1) 放大 2 倍
-1 50% (2^-1) 缩小一半
-1.76 ~30% Obsidian 默认缩放

重要:不要设置 zoom = 1.0,这会让 Canvas 放大到 200%!应该设置 zoom = 0。

4. 链接更新

Canvas 引用会被转换为 Viewer 组件:

updateCanvasLinksInContent(content: string, canvasFile: TFile, canvasPath: string, imageFileName: string): string {
    // 支持多种路径格式
    const possiblePaths = [
        canvasPath,                                    // 完整路径
        canvasPath.substring(sourceDir.length + 1),   // 相对路径
        encodeURI(canvasPath),                        // URL 编码
        canvasFile.name,                              // 文件名
        canvasFile.name.replace('.canvas', ''),       // 不带扩展名
    ];

    // 替换 Markdown 格式
    const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
    content = content.replace(markdownRegex, (match, alt, path) => {
        if (path匹配) {
            const relativePath = this.settings.assetsPath.replace('docs/', '');
            return `<Viewer src="${relativePath}/${imageFileName}" />`;
        }
        return match;
    });

    // 替换 Wikilink 格式
    const wikilinkRegex = /!\[\[([^\]]+)\]\]/g;
    content = content.replace(wikilinkRegex, (match, link) => {
        if (link匹配) {
            const relativePath = this.settings.assetsPath.replace('docs/', '');
            return `<Viewer src="${relativePath}/${imageFileName}" />`;
        }
        return match;
    });

    return content;
}

渲染流程

1. 打开 Canvas 文件
   └─> activeLeaf.openFile(canvasFile)

2. 获取 Canvas 对象
   └─> activeLeaf.view.canvas

3. 调用 exportCanvasWithInternalAPI
   ├─> 获取视口边界框
   ├─> 设置 zoom = 0 (100%)
   ├─> 请求重绘
   ├─> 等待 100ms + requestAnimationFrame
   ├─> 调用 takeScreenshot(绝对路径)
   ├─> 读取临时文件
   ├─> 删除临时文件
   └─> 写入到 docs/assets/

4. 更新内容中的链接
   └─> 转换为 <Viewer src="assets/xxx.png" />

在资源处理中的集成

Canvas 渲染是资源处理流程的一部分:

async processAssets(content: string, sourceFile: TFile): Promise<string> {
    const cache = metadataCache.getCache(sourceFile.path);
    const canvasFiles = new Set<string>();

    // 1. 收集 Canvas 文件
    if (cache.embeds) {
        for (const embed of cache.embeds) {
            const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path);
            if (resolvedLink?.extension === 'canvas') {
                canvasFiles.add(resolvedLink.path);
            }
        }
    }

    // 2. 渲染每个 Canvas
    for (const canvasPath of canvasFiles) {
        try {
            const canvasFile = vault.getAbstractFileByPath(canvasPath);
            if (canvasFile instanceof TFile) {
                const renderedImage = await this.captureCanvas(canvasFile);
                if (renderedImage) {
                    const imageFileName = canvasFile.name.replace('.canvas', '.png');
                    const imagePath = `${this.settings.assetsPath}/${imageFileName}`;

                    // 确保 assets 目录存在
                    await vault.adapter.mkdir(this.settings.assetsPath);

                    // 写入渲染的图片
                    await vault.adapter.writeBinary(imagePath, renderedImage.buffer);

                    // 更新内容中的 canvas 引用
                    content = this.updateCanvasLinksInContent(content, canvasFile, canvasPath, imageFileName);
                }
            }
        } catch (error) {
            console.error(`Failed to render canvas ${canvasPath}:`, error);
        }
    }

    return content;
}

优势

  1. 完全使用 Obsidian 内部 API

    • 不依赖第三方库(html-to-image)
    • 兼容性好,Obsidian 升级时应该能正常工作
    • 代码简洁,维护方便
  2. 正确的缩放控制

    • 使用对数刻度的 zoom 属性
    • 设置 zoom = 0 获得准确的 100% 缩放
    • 每个元素都是原始尺寸
  3. 路径处理灵活

    • 支持多种路径格式(绝对、相对、URL 编码)
    • 自动转换为正确的相对路径
    • 支持 Markdown 和 Wikilink 两种格式
  4. 错误隔离

    • 单个 Canvas 渲染失败不影响其他文件
    • 在控制台输出详细错误信息
    • 继续处理后续资源

测试方法

1. 基本功能测试

创建测试 Canvas 文件:

  • 小尺寸: 800x600
  • 大尺寸: 4000x3000
  • 包含多个节点和连接

2. 验证步骤

  1. 创建测试笔记,包含 Canvas 引用

    # 测试笔记
    
    这是一个 Canvas:
    ![[test.canvas]]
    
  2. 执行导出命令

  3. 检查控制台输出:

    Processing assets for file: test.md
    Canvas files to render: ['test.canvas']
    Successfully rendered canvas: test.canvas -> docs/assets/test.png
    
  4. 检查输出文件:

    • 图片位置:docs/assets/test.png
    • 图片尺寸应该等于 Canvas 的实际内容尺寸
    • 文字清晰,没有缩水
    • 导出后的笔记内容被更新

3. 路径格式测试

测试各种路径格式的 Canvas 引用:

![[test.canvas]]                                    # 文件名
![[folder/test.canvas]]                             # 相对路径
![](test.canvas)                                    # Markdown 格式
![](folder/test.canvas)                             # Markdown 相对路径
![[test.canvas|别名]]                               # 带别名
![](%E6%B5%8B%E8%AF%95.canvas)                     # URL 编码

所有格式都应该被正确识别和转换。

API 参考

Canvas 内部方法

方法 参数 返回值 说明
getViewportBBox() - {minX, minY, maxX, maxY} 获取视口边界框
takeScreenshot(path, privacy) path: string, privacy: boolean Promise<void> 截图并保存到文件
requestFrame() - - 请求重绘
zoom 属性 - number 对数刻度缩放(0 = 100%)

Workspace API

方法 参数 返回值 说明
getLeaf() - WorkspaceLeaf 获取活动 leaf
openFile(file) TFile Promise<void> 打开文件

Vault API

方法 参数 返回值 说明
adapter.readBinary(path) string Promise<ArrayBuffer> 读取二进制文件
adapter.writeBinary(path, data) string, ArrayBuffer Promise<void> 写入二进制文件
adapter.mkdir(path) string Promise<void> 创建目录
adapter.remove(path) string Promise<void> 删除文件

重要常量

  • zoom = 0 → 100% 缩放
  • zoom = 1 → 200% 缩放
  • zoom = -1 → 50% 缩放
  • zoom = Math.log2(scale) → zoom 和缩放的转换公式

已知问题

  1. 临时文件处理

    • takeScreenshot 需要绝对文件系统路径
    • 必须先保存到临时文件,然后读取并移动
    • 临时文件会自动清理
  2. 路径获取

    • 使用 vault.adapter.basePath 获取 vault 的完整路径
    • 这是 Obsidian 的内部 API,可能在未来版本中变化
  3. 同步问题

    • 需要等待 takeScreenshot 完成
    • 使用 setTimeout 和 requestAnimationFrame 确保渲染完成
    • 时间可能需要根据实际情况调整

故障排除

Canvas 渲染失败

问题:Canvas 没有被渲染成图片

可能原因:

  1. Canvas 文件路径不正确
  2. Canvas 对象获取失败
  3. takeScreenshot 调用失败
  4. 临时文件读写权限问题

解决方法:

  • 检查控制台日志
  • 确认 Canvas 文件存在且可打开
  • 检查文件系统权限

图片质量差

问题:渲染的图片模糊或尺寸不正确

可能原因:

  • zoom 值设置不正确
  • Canvas 节点被折叠

解决方法:

  • 确认 zoom = 0(100%)
  • 检查 Canvas 是否有折叠的节点
  • 增加等待时间确保完全渲染

链接没有更新

问题:导出后 Canvas 引用没有被替换

可能原因:

  • 路径格式不支持
  • 正则表达式不匹配

解决方法:

  • 检查链接格式(Markdown vs Wikilink)
  • 查看控制台日志确认路径匹配
  • 添加更多路径格式支持

未来改进

  1. 进度反馈

    • 添加渲染进度提示
    • 显示 Canvas 尺寸和预计时间
  2. 错误处理

    • 更详细的错误信息
    • 降级方案(如果 takeScreenshot 不可用)
  3. 性能优化

    • 缓存已渲染的 Canvas
    • 避免重复截图
    • 并行处理多个 Canvas
  4. 更多格式

    • 支持 JPEG、WebP 等格式
    • 可配置的图片质量
    • 可配置的分辨率