# Canvas 渲染系统实现 ## 技术栈 - **渲染方式**: Obsidian Canvas 内部 API (`takeScreenshot`) - **缩放控制**: `canvas.zoom` 属性(对数刻度) - **语言**: TypeScript ## 核心实现 ### 1. Canvas 捕获流程 ```typescript async renderCanvas(canvasFile: TFile): Promise { // 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 ```typescript async exportCanvasWithInternalAPI( canvas: any, canvasFile: TFile ): Promise { 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 组件: ```typescript 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 ``; } return match; }); // 替换 Wikilink 格式 const wikilinkRegex = /!\[\[([^\]]+)\]\]/g; content = content.replace(wikilinkRegex, (match, link) => { if (link匹配) { const relativePath = this.settings.assetsPath.replace('docs/', ''); return ``; } 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. 更新内容中的链接 └─> 转换为 ``` ## 在资源处理中的集成 Canvas 渲染是资源处理流程的一部分: ```typescript async processAssets(content: string, sourceFile: TFile): Promise { const cache = metadataCache.getCache(sourceFile.path); const canvasFiles = new Set(); // 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 引用 ```markdown # 测试笔记 这是一个 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 引用: ```markdown ![[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` | 截图并保存到文件 | | `requestFrame()` | - | - | 请求重绘 | | `zoom` 属性 | - | `number` | 对数刻度缩放(0 = 100%) | ### Workspace API | 方法 | 参数 | 返回值 | 说明 | |------|------|--------|------| | `getLeaf()` | - | `WorkspaceLeaf` | 获取活动 leaf | | `openFile(file)` | `TFile` | `Promise` | 打开文件 | ### Vault API | 方法 | 参数 | 返回值 | 说明 | |------|------|--------|------| | `adapter.readBinary(path)` | `string` | `Promise` | 读取二进制文件 | | `adapter.writeBinary(path, data)` | `string, ArrayBuffer` | `Promise` | 写入二进制文件 | | `adapter.mkdir(path)` | `string` | `Promise` | 创建目录 | | `adapter.remove(path)` | `string` | `Promise` | 删除文件 | ### 重要常量 - `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 等格式 - 可配置的图片质量 - 可配置的分辨率