feat: Implement Canvas rendering system and VitePress exporter plugin
Some checks failed
Node.js build / build (20.x) (push) Failing after 18m32s
Node.js build / build (22.x) (push) Has been cancelled

- 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`
This commit is contained in:
2026-04-20 22:23:23 +08:00
parent e54a8a3b05
commit 41db190fee
27 changed files with 5613 additions and 4912 deletions

424
notes/architecture.md Normal file
View File

@@ -0,0 +1,424 @@
# VitePress Exporter 架构设计
## 概述
VitePress Exporter 是一个 Obsidian 插件,用于将笔记和资源导出到 VitePress 格式。核心功能包括:
- 导出 Markdown 文件到 `docs/` 目录
- 复制图片等资源到 `docs/assets/` 目录
- 将 Obsidian Canvas 渲染为图片
## 技术栈
- **语言**: TypeScript
- **构建工具**: esbuild
- **包管理器**: pnpm
- **Obsidian API**: Plugin, Workspace, TFile, Vault, MetadataCache
## 核心架构
### 1. 导出流程
```
用户触发导出
↓
exportCurrentNote()
↓
exportNote(file)
↓
processAssets(content, file)
├─→ 扫描嵌入和链接(使用 MetadataCache)
├─→ 处理 Canvas 文件 (captureCanvas)
│ ├─→ 打开 Canvas 文件
│ ├─→ 获取 Canvas 对象
│ └─→ exportCanvasWithInternalAPI
│ ├─→ 设置 zoom = 0 (100%)
│ ├─→ 调用 takeScreenshot
│ └─→ 保存图片
└─→ 复制资源文件
├─→ 使用 binary 模式读取
└─→ 写入到 docs/assets/
↓
updateLinksInContent() / updateCanvasLinksInContent()
↓
写入 docs/ 目录(使用 binary 模式)
```
### 2. 文件结构
```
vitepress-exporter/
├── src/
│ ├── main.ts # 主插件源代码
│ └── settings.ts # 设置相关代码
├── manifest.json # 插件清单
├── package.json # 项目配置
├── esbuild.config.mjs # 构建配置
├── tsconfig.json # TypeScript 配置
└── notes/ # 开发文档
├── architecture.md # 本文档
├── canvas-rendering.md # Canvas 渲染详细说明
└── flowchart.md # 流程图
```
### 3. 关键类和方法
#### VitePressExporterPlugin
主插件类,继承自 `Plugin`:
```typescript
class VitePressExporterPlugin extends Plugin {
settings: VitePressExporterSettings;
// 生命周期
async onload() // 插件加载,注册命令和菜单
onunload() // 插件卸载
// 设置管理
async loadSettings() // 加载设置
async saveSettings() // 保存设置
// 导出功能
async exportCurrentNote() // 导出当前笔记
async exportNote(file) // 导出单个笔记
// 资源处理
async processAssets(content, file) // 处理资源文件和 Canvas
// Canvas 渲染
async captureCanvas(canvasFile) // 捕获 Canvas 为图片
async exportCanvasWithInternalAPI(canvas, canvasFile) // 使用内部 API
// 链接更新
updateLinksInContent(content, sourceFile, assetPath, newFileName) // 更新资源链接
updateCanvasLinksInContent(content, canvasFile, canvasPath, imageFileName) // 更新 Canvas 链接
}
```
## Canvas 渲染架构
### 核心决策
**使用 Obsidian Canvas 内部 API**,而不是自己实现渲染逻辑:
- ✅ `canvas.takeScreenshot()` - 直接截图
- ✅ `canvas.getViewportBBox()` - 获取边界框
- ✅ `canvas.zoom` - 控制缩放(对数刻度)
- ✅ `canvas.requestFrame()` - 触发重绘
**为什么不使用第三方库**:
- ❌ `html-to-image` - 需要滑动窗口,代码复杂
- ❌ `dom-to-image-more` - 维护不活跃
- ✅ Obsidian 内部 API - 兼容性好,代码简洁
### Zoom 对数刻度
**关键发现**:`canvas.zoom` 使用对数刻度(base 2)
```typescript
zoom = 0 → scale = 2^0 = 1.0 (100%)
zoom = 1 → scale = 2^1 = 2.0 (200%)
zoom = -1 → scale = 2^-1 = 0.5 (50%)
```
**重要**:设置 `zoom = 0` 获得准确的 100% 缩放,不要设置 `zoom = 1.0`!
### 文件路径处理
`takeScreenshot` 需要绝对文件系统路径:
```typescript
// 1. 获取 vault 的完整路径
const vaultBasePath = vault.adapter.basePath;
// 2. 构建临时文件的绝对路径
const tempFullPath = `${vaultBasePath}/temp_canvas_${Date.now()}.png`;
// 3. 调用 takeScreenshot(需要绝对路径)
await canvas.takeScreenshot(tempFullPath, false);
// 4. 使用相对路径读取(vault API)
const imageBuffer = await vault.adapter.readBinary(tempFileName);
// 5. 删除临时文件
await vault.adapter.remove(tempFileName);
// 6. 写入到正确位置(相对路径)
await vault.adapter.writeBinary(finalPath, imageBuffer);
```
## 资源处理
### 资源类型
1. **嵌入资源** (`![[image.png]]`)
- 图片、PDF 等非 Markdown 文件
- 自动复制到 `docs/assets/`
- 使用 binary 模式保留原始内容
2. **Canvas 文件** (`![[diagram.canvas]]`)
- 使用内部 API 渲染为 PNG 图片
- 替换为 `<Viewer src="assets/diagram.png" />`
3. **普通链接** (`[[other-file]]`)
- Markdown 文件不复制
- 其他资源文件会被复制
### 资源扫描机制
使用 Obsidian 的 `MetadataCache` API:
```typescript
const cache = metadataCache.getCache(sourceFile.path);
// 嵌入文件
if (cache.embeds) {
for (const embed of cache.embeds) {
const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path);
// 检查是否是 Canvas 或其他资源
}
}
// 普通链接
if (cache.links) {
for (const link of cache.links) {
const resolvedLink = metadataCache.getFirstLinkpathDest(link.link, sourceFile.path);
// 只复制非 Markdown 文件
}
}
```
### 路径更新
资源链接会自动更新为相对于 `docs/` 的路径:
**Markdown 格式**:
```markdown
之前: ![](images/screenshot.png)
之后: ![](assets/screenshot.png)
```
**Wikilink 格式**:
```markdown
之前: ![[images/screenshot.png]]
之后: ![[assets/screenshot.png]]
```
**Canvas 特殊处理**:
```markdown
之前: ![[diagram.canvas]]
之后: <Viewer src="assets/diagram.png" />
```
### 路径解析策略
支持多种路径格式的智能匹配:
```typescript
const possiblePaths = [
assetPath, // 完整路径
assetPath.substring(sourceDir.length + 1), // 相对路径
encodeURI(assetPath), // URL 编码
encodeURI(assetPath.substring(sourceDir.length + 1)),
sourceFile.name, // 文件名
encodeURI(sourceFile.name)
];
```
## 配置
### 插件设置
```typescript
interface VitePressExporterSettings {
docsPath: string; // 默认: 'docs'
assetsPath: string; // 默认: 'docs/assets'
}
```
### Obsidian 配置
插件通过 Obsidian 的设置界面配置:
1. 设置 → 社区插件 → VitePress Exporter → 选项
2. 配置 `Docs path` 和 `Assets path`
3. 保存设置
## 文件读写
### Binary 模式
所有文件操作都使用 binary 模式,以保留原始格式:
```typescript
// 读取文件
const contentBuffer = await vault.readBinary(file);
const content = new TextDecoder().decode(contentBuffer);
// 写入文件
const updatedBuffer = new TextEncoder().encode(updatedContent);
await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer);
```
**优势**:
- 保留行尾空格
- 保留特殊字符
- 不修改原始格式
## 错误处理
### Canvas 渲染失败
当 Canvas 渲染失败时,会在控制台输出错误信息,但不会中断整个导出流程:
```typescript
try {
const renderedImage = await this.captureCanvas(canvasFile);
if (renderedImage) {
// 成功处理
}
} catch (error) {
console.error(`Failed to render canvas ${canvasPath}:`, error);
}
// 继续处理其他文件
```
### 资源复制失败
单个资源文件复制失败不会影响其他文件:
```typescript
try {
// 复制资源
} catch (error) {
console.error(`Failed to copy asset ${assetPath}:`, error);
}
// 继续处理下一个资源
```
## 性能考虑
### 优化策略
1. **使用 MetadataCache**
- 快速获取文件的嵌入和链接信息
- 避免手动解析 Markdown
2. **批量处理**
- 一次性收集所有需要处理的文件
- 批量复制和更新
3. **异步操作**
- 所有文件操作都是异步的
- 不阻塞 UI 线程
4. **Binary 模式**
- 减少字符串转换开销
- 直接操作二进制数据
### 预期性能
- 小笔记(< 10 个资源):< 1 秒
- 中等笔记(10-50 个资源):< 3 秒
- 大笔记(> 50 个资源或多个 Canvas):< 10 秒
## 触发方式
插件提供三种导出触发方式:
1. **命令面板**
- `Export current note to VitePress`
2. **文件菜单**
- 右键点击文件 → `Export to VitePress`
3. **编辑器菜单**
- 右键点击编辑器 → `Export to VitePress`
## 未来扩展
### 可能的功能
1. **批量导出**
- 导出整个文件夹
- 导出所有笔记
2. **高级配置**
- 自定义图片格式(PNG/JPEG/WebP)
- 图片质量控制
- 自定义链接替换规则
3. **VitePress 集成**
- 自动生成 frontmatter
- 自动创建索引页面
- 集成 VitePress 构建流程
4. **更多资源类型**
- 支持视频嵌入
- 支持音频文件
- 支持其他特殊格式
## 维护指南
### 修改导出逻辑
主要导出逻辑在 `exportNote()` 方法中:
```typescript
async exportNote(file: TFile) {
// 1. 读取文件(binary 模式)
const contentBuffer = await vault.readBinary(file);
const content = new TextDecoder().decode(contentBuffer);
// 2. 处理资源
const updatedContent = await this.processAssets(content, file);
// 3. 写入文件(binary 模式)
const updatedBuffer = new TextEncoder().encode(updatedContent);
await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer);
}
```
### 添加新的资源类型
在 `processAssets()` 方法中添加:
```typescript
if (cache.embeds) {
for (const embed of cache.embeds) {
const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path);
if (resolvedLink) {
if (resolvedLink.extension === 'canvas') {
// Canvas 处理
} else if (resolvedLink.extension === 'newtype') {
// 处理新类型
} else {
// 普通资源
}
}
}
}
```
### 调试技巧
1. **启用详细日志**
```typescript
console.log('Processing assets for file:', sourceFile.path);
console.log('Cache:', cache);
console.log('Assets to copy:', Array.from(assetsToCopy));
```
2. **使用 Obsidian 开发者工具**
- `Ctrl+Shift+I` 打开控制台
- 查看日志输出
3. **测试单个功能**
- 创建最小测试用例
- 逐步验证每个步骤
## 相关文档
- [Canvas 渲染详细说明](./canvas-rendering.md) - Canvas 渲染的实现细节
- [执行流程图](./flowchart.md) - 完整的导出流程图
- [Obsidian API 文档](https://github.com/obsidianmd/obsidian-api) - 官方 API

411
notes/flowchart.md Normal file
View File

@@ -0,0 +1,411 @@
# VitePress Exporter 执行流程
## 插件概述
VitePress Exporter 是一个 Obsidian 插件,用于将当前笔记导出到 VitePress 文档系统,包括资源文件管理和 Canvas 渲染功能。
## 完整执行流程图
```mermaid
flowchart TD
Start([用户触发导出]) --> CheckActiveFile{检查当前文件}
CheckActiveFile -->|没有活动文件| ShowError1[显示错误提示]
CheckActiveFile -->|不是 md 文件| ShowError2[显示错误提示]
CheckActiveFile -->|是 md 文件| ReadFile[读取文件内容<br/>使用 binary 模式]
ReadFile --> ProcessAssets[处理资源文件<br/>processAssets]
ProcessAssets --> GetCache[获取文件元数据缓存<br/>metadataCache.getCache]
GetCache --> CheckCache{缓存是否存在}
CheckCache -->|否| ReturnContent[返回原内容]
CheckCache -->|是| InitializeSets[初始化集合<br/>assetsToCopy, canvasFiles]
InitializeSets --> ScanEmbeds[扫描嵌入文件<br/>cache.embeds]
ScanEmbeds --> CheckEmbed{有嵌入文件?}
CheckEmbed -->|否| ScanLinks
CheckEmbed -->|是| ResolveEmbed[解析嵌入路径<br/>getFirstLinkpathDest]
ResolveEmbed --> CheckCanvas{是 canvas 文件?}
CheckCanvas -->|是| AddToCanvasSet[添加到 canvasFiles]
CheckCanvas -->|否| AddToAssetsSet[添加到 assetsToCopy]
AddToCanvasSet --> ScanEmbeds
AddToAssetsSet --> ScanEmbeds
ScanEmbeds --> ScanLinks[扫描链接文件<br/>cache.links]
ScanLinks --> CheckLink{有链接文件?}
CheckLink -->|否| ProcessCanvasFiles
CheckLink -->|是| ResolveLink[解析链接路径<br/>getFirstLinkpathDest]
ResolveLink --> CheckMD{是 md 文件?}
CheckMD -->|是| SkipLink[跳过]
CheckMD -->|否| AddToAssetsSet
SkipLink --> ScanLinks
AddToAssetsSet --> ScanLinks
ScanLinks --> ProcessCanvasFiles[处理 Canvas 文件]
ProcessCanvasFiles --> CheckCanvasEmpty{有 Canvas 文件?}
CheckCanvasEmpty -->|否| ProcessAssetFiles
CheckCanvasEmpty -->|是| GetCanvasFile[获取 Canvas 文件对象]
GetCanvasFile --> CaptureCanvas[captureCanvas]
CaptureCanvas --> OpenFile[打开 Canvas 文件<br/>activeLeaf.openFile]
OpenFile --> GetCanvas[获取 Canvas 对象<br/>activeLeaf.view.canvas]
GetCanvas --> CheckCanvasObj{Canvas 对象存在?}
CheckCanvasObj -->|否| NextCanvas
CheckCanvasObj -->|是| ExportInternalAPI[exportCanvasWithInternalAPI]
ExportInternalAPI --> GetBBox[获取视口边界框<br/>getViewportBBox]
GetBBox --> SetZoom[设置 zoom = 0<br/>100% 缩放]
SetZoom --> RequestFrame[请求重绘<br/>requestFrame]
RequestFrame --> WaitRender[等待渲染<br/>100ms + rAF]
WaitRender --> TakeScreenshot[调用 takeScreenshot<br/>使用绝对路径]
TakeScreenshot --> WaitWrite[等待文件写入<br/>100ms]
WaitWrite --> ReadTemp[读取临时文件<br/>readBinary]
ReadTemp --> CheckBuffer{读取成功?}
CheckBuffer -->|否| LogError1[记录错误]
CheckBuffer -->|是| RemoveTemp[删除临时文件]
RemoveTemp --> WriteFinal[写入最终位置<br/>writeBinary]
WriteFinal --> UpdateCanvasLinks[更新 Canvas 链接<br/>updateCanvasLinksInContent]
UpdateCanvasLinks --> NextCanvas{还有 Canvas?}
LogError1 --> NextCanvas
NextCanvas -->|是| GetCanvasFile
NextCanvas -->|否| ProcessAssetFiles
subgraph 资源复制流程
ProcessAssetFiles[处理资源文件] --> CheckAssets{有资源文件?}
CheckAssets -->|否| WriteDocs
CheckAssets -->|是| GetAssetFile[获取资源文件对象]
GetAssetFile --> ReadAsset[读取资源文件<br/>readBinary binary 模式]
ReadAsset --> MkdirAssets[创建 assets 目录<br/>mkdir]
MkdirAssets --> WriteAsset[写入到 docs/assets<br/>writeBinary binary 模式]
WriteAsset --> UpdateLinks[更新资源链接<br/>updateLinksInContent]
UpdateLinks --> NextAsset{还有资源?}
NextAsset -->|是| GetAssetFile
NextAsset -->|否| WriteDocs
end
WriteDocs[写入到 docs 目录<br/>writeBinary binary 模式] --> Success[显示成功提示]
Success --> End([结束])
ShowError1 --> End
ShowError2 --> End
ReturnContent --> WriteDocs
style Start fill:#e1f5e1
style End fill:#ffe1e1
style Success fill:#e1f5e1
style ShowError1 fill:#ffe1e1
style ShowError2 fill:#ffe1e1
style ProcessCanvasFiles fill:#fff4e1
style ProcessAssetFiles fill:#e1f0ff
style ExportInternalAPI fill:#ffe1ff
```
## 核心功能模块
### 1. 导出流程
**触发方式**:
- 命令面板:`Export current note to VitePress`
- 文件右键菜单:`Export to VitePress`
- 编辑器右键菜单:`Export to VitePress`
**文件验证**:
- 检查活动文件是否存在
- 验证文件扩展名是否为 `md`
**内容读取**:
- 使用 `vault.readBinary()` 读取文件
- 保留原始格式(包括行尾空格)
### 2. 资源处理
**扫描机制**:
- 使用 `metadataCache.getCache()` 获取文件元数据
- 扫描 `cache.embeds` 获取嵌入文件
- 扫描 `cache.links` 获取链接文件
**文件分类**:
- Canvas 文件:扩展名为 `.canvas`
- 普通资源:非 Markdown 文件
- Markdown 文件:不复制
**路径解析**:
- 使用 `metadataCache.getFirstLinkpathDest()` 解析路径
- 支持相对路径和绝对路径
### 3. Canvas 渲染
**打开文件**:
- 使用 `workspace.getLeaf()` 获取 leaf
- 调用 `activeLeaf.openFile()` 打开 Canvas
- 从 `activeLeaf.view.canvas` 获取 Canvas 对象
**渲染过程**:
- 获取视口边界框 `getViewportBBox()`
- 设置 `zoom = 0`(100% 缩放)
- 请求重绘 `requestFrame()`
- 等待渲染完成(100ms + requestAnimationFrame)
- 调用 `takeScreenshot(绝对路径)` 截图
**文件处理**:
- 保存到临时文件(绝对路径)
- 读取临时文件(相对路径)
- 删除临时文件
- 写入到最终位置
### 4. 链接更新
**Markdown 格式**:
```markdown
之前: ![](path/to/image.png)
之后: ![](assets/image.png)
```
**Wikilink 格式**:
```markdown
之前: ![[path/to/image.png]]
之后: ![[assets/image.png]]
```
**Canvas 特殊处理**:
```markdown
之前: ![[diagram.canvas]]
之后: <Viewer src="assets/diagram.png" />
```
**路径匹配**:
- 支持多种格式(绝对、相对、URL 编码)
- 使用正则表达式匹配和替换
- 转换为相对路径
### 5. 文件写入
**Binary 模式**:
- 使用 `TextEncoder.encode()` 转换内容
- 调用 `vault.adapter.writeBinary()` 写入
- 保留原始格式
## 关键方法说明
### exportCurrentNote()
导出当前活动笔记
```typescript
async exportCurrentNote() {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
new Notice('No active file found');
return;
}
if (activeFile.extension !== 'md') {
new Notice('Current file is not a markdown file');
return;
}
await this.exportNote(activeFile);
}
```
### exportNote(file)
执行导出逻辑
```typescript
async exportNote(file: TFile) {
// 1. 读取文件(binary 模式)
const contentBuffer = await vault.readBinary(file);
const content = new TextDecoder().decode(contentBuffer);
// 2. 处理资源
const updatedContent = await this.processAssets(content, file);
// 3. 写入文件(binary 模式)
const docsPath = `${this.settings.docsPath}/${file.name}`;
const updatedBuffer = new TextEncoder().encode(updatedContent);
await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer);
}
```
### processAssets(content, file)
处理资源文件
```typescript
async processAssets(content: string, sourceFile: TFile): Promise<string> {
const cache = metadataCache.getCache(sourceFile.path);
if (!cache) return content;
const assetsToCopy = new Set<string>();
const canvasFiles = new Set<string>();
// 收集嵌入文件
if (cache.embeds) {
for (const embed of cache.embeds) {
const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path);
if (resolvedLink) {
if (resolvedLink.extension === 'canvas') {
canvasFiles.add(resolvedLink.path);
} else {
assetsToCopy.add(resolvedLink.path);
}
}
}
}
// 收集链接文件
if (cache.links) {
for (const link of cache.links) {
const resolvedLink = metadataCache.getFirstLinkpathDest(link.link, sourceFile.path);
if (resolvedLink && resolvedLink.extension !== 'md') {
assetsToCopy.add(resolvedLink.path);
}
}
}
// 处理 Canvas 文件
for (const canvasPath of canvasFiles) {
const renderedImage = await this.captureCanvas(canvasFile);
if (renderedImage) {
// 写入并更新链接
}
}
// 复制资源文件
for (const assetPath of assetsToCopy) {
const assetContent = await vault.readBinary(sourceFile);
await vault.adapter.writeBinary(destPath, assetContent);
content = this.updateLinksInContent(content, ...);
}
return content;
}
```
### captureCanvas(canvasFile)
捕获 Canvas 为图片
```typescript
async captureCanvas(canvasFile: TFile): Promise<Uint8Array | null> {
const activeLeaf = this.app.workspace.getLeaf();
await activeLeaf.openFile(canvasFile);
const canvas = activeLeaf.view.canvas;
if (!canvas) return null;
return await this.exportCanvasWithInternalAPI(canvas, canvasFile);
}
```
## 数据流图
```
用户触发
↓
exportCurrentNote()
↓
exportNote(file)
↓
readBinary(file) → content
↓
processAssets(content, file)
├─→ metadataCache.getCache() → cache
├─→ 收集文件 → canvasFiles, assetsToCopy
├─→ 处理 Canvas
│ ├─→ captureCanvas()
│ ├─→ takeScreenshot()
│ └─→ updateCanvasLinksInContent()
└─→ 复制资源
├─→ readBinary()
├─→ writeBinary()
└─→ updateLinksInContent()
↓
updatedContent
↓
TextEncoder.encode() → buffer
↓
writeBinary(docsPath, buffer)
↓
完成
```
## 错误处理
### 文件验证失败
- 没有活动文件 → 显示 Notice
- 文件不是 Markdown → 显示 Notice
### Canvas 渲染失败
- 捕获异常并记录日志
- 继续处理其他文件
- 不中断整个导出流程
### 资源复制失败
- 捕获异常并记录日志
- 继续处理下一个资源
- 不影响其他文件
## 性能特点
### 优化策略
1. **使用 MetadataCache**
- 快速获取文件关系
- 避免手动解析
2. **Binary 模式**
- 减少字符串转换
- 保留原始格式
3. **异步操作**
- 所有文件操作异步
- 不阻塞 UI
4. **错误隔离**
- 单个文件失败不影响其他
- 继续处理后续文件
### 时间复杂度
- 文件读取:O(n)
- 资源扫描:O(e + l) 其中 e=嵌入数,l=链接数
- Canvas 渲染:O(c) 其中 c=Canvas 数量
- 资源复制:O(a) 其中 a=资源数量
总体:O(n + e + l + c + a)
## 配置选项
用户可以在插件设置中配置:
- **docsPath**: 导出文档目录(默认: `docs`)
- **assetsPath**: 资源文件目录(默认: `docs/assets`)
## 未来改进方向
1. **批量导出**
- 导出整个文件夹
- 导出多个选中的文件
2. **进度显示**
- 显示导出进度条
- 显示当前处理的文件
3. **更多配置**
- 自定义图片格式
- 自定义链接转换规则
- 排除特定文件类型
4. **性能优化**
- 并行处理资源
- 缓存已渲染的 Canvas
- 增量导出(只处理修改的文件)

397
notes/render-canvas.md Normal file
View File

@@ -0,0 +1,397 @@
# 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 等格式
- 可配置的图片质量
- 可配置的分辨率