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

398 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Canvas 渲染系统实现
## 技术栈
- **渲染方式**: Obsidian Canvas 内部 API (`takeScreenshot`)
- **缩放控制**: `canvas.zoom` 属性(对数刻度)
- **语言**: TypeScript
## 核心实现
### 1. Canvas 捕获流程
```typescript
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
```typescript
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 组件:
```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 `<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 渲染是资源处理流程的一部分:
```typescript
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 引用
```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<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 等格式
- 可配置的图片质量
- 可配置的分辨率