feat: Enhance Canvas rendering with internal API and improved export functionality

This commit is contained in:
2026-04-20 23:13:12 +08:00
parent 41db190fee
commit 3cf2cb9339

View File

@@ -272,8 +272,10 @@ export default class VitePressExporterPlugin extends Plugin {
return content;
}
// 尝试捕获 Obsidian 原生 Canvas 渲染(通过临时调整视口大小)
async renderCanvas(canvasFile: TFile): Promise<Uint8Array | null> {
// Use Canvas internal API to export (基于 Obsidian export as image 实现)
async renderCanvas(
canvasFile: TFile
): Promise<Uint8Array | null> {
const activeLeaf = this.app.workspace.getLeaf();
if (!activeLeaf) {
@@ -282,113 +284,219 @@ export default class VitePressExporterPlugin extends Plugin {
}
await activeLeaf.openFile(canvasFile);
//@ts-ignore
const canvas = activeLeaf.view.canvas;
if (!canvas) {
console.log("Could not get canvas object from view");
return null;
}
// Use Canvas internal API
const imageData = await this.exportCanvasWithInternalAPI(canvas, canvasFile);
if (!imageData) {
console.log("Canvas internal API capture failed");
return null;
}
console.log("Canvas internal API capture successful!");
return imageData;
// 选项设置(基于用户要求)
const zoomScale = 1.0; // 缩放比例 1.0 (100%)
const privacyMode = false; // 不启用隐私模式
}
// Use Canvas internal API to export
async exportCanvasWithInternalAPI(
canvas: any,
canvasFile: TFile
): Promise<Uint8Array | null> {
try {
console.log("=== Using Canvas Internal API ===");
// 获取系统参数
const win = window;
//@ts-ignore
const electron = win.electron?.remote;
const webContents = electron?.getCurrentWebContents();
const zoomLevel = webContents?.getZoomLevel() || 0;
const o = Math.pow(1.2, zoomLevel); // 系统缩放因子
const a = win.devicePixelRatio || 1; // 设备像素比
const s = Math.round(a / o * 1e5) / 1e5; // 缩放校正系数
const { vault } = this.app;
// 获取 Canvas 边界
const nodes = Array.from(canvas.nodes.values());
const bbox = this.calculateBBox(nodes);
const c = bbox.maxX - bbox.minX; // 宽度
const u = bbox.maxY - bbox.minY; // 高度
// 1. Get viewport bbox
// 计算最大缩放限制(避免超过 16284px)
const h = Math.min(2, 16284 / s / c, 16284 / s / u);
const d = Math.min(zoomScale, h); // 实际使用的缩放比例
console.log(`Canvas export: scale=${d}, size=${c}x${u}`);
// Full canvas 模式 - 使用分块渲染
const e = canvas.wrapperEl;
// 准备 Canvas 环境
this.deselectAll();
e.addClass("is-screenshotting");
if (privacyMode) {
e.addClass("is-text-garbled");
}
// @ts-ignore
const bbox = canvas.getViewportBBox();
console.log("Viewport bbox:", bbox);
canvas.screenshotting = true;
document.body.appendChild(e);
// 2. Calculate content size
const contentWidth = bbox.maxX - bbox.minX;
const contentHeight = bbox.maxY - bbox.minY;
console.log(`Content size: ${contentWidth}x${contentHeight}`);
try {
// 设置 Canvas 位置
const x = 5 / o;
e.setCssStyles({
top: `${x}px`,
left: `${x}px`,
bottom: `${x}px`,
right: `${x}px`,
width: "auto",
height: "auto"
});
// 3. Set zoom = 0 (100%)
// @ts-ignore
const oldZoom = canvas.zoom;
// @ts-ignore
canvas.zoom = 0;
console.log(`Set zoom from ${oldZoom} to 0 (100%)`);
// 计算视口
const P = e.getBoundingClientRect();
const L = Math.ceil(P.x * o + 1);
const I = Math.ceil(P.y * o + 1);
const O = Math.floor(P.width * o) - 2;
const F = Math.floor(P.height * o) - 2;
// 4. Request frame
// @ts-ignore
canvas.requestFrame();
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise(resolve => requestAnimationFrame(resolve));
// 设置缩放
// @ts-ignore
canvas.zoom = Math.log2(d / o);
// @ts-ignore
canvas.tZoom = canvas.zoom;
// @ts-ignore
canvas.viewportChanged = true;
canvas.requestFrame();
// 等待渲染
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise(resolve => requestAnimationFrame(resolve));
// Generate temp filename (relative path)
const tempFileName = `temp_canvas_${Date.now()}.png`;
// 计算分块数量
const tileWidth = O / d;
const tileHeight = F / d;
const cols = Math.ceil((bbox.maxX - bbox.minX) / tileWidth);
const rows = Math.ceil((bbox.maxY - bbox.minY) / tileHeight);
const startX = bbox.minX + tileWidth / 2;
const startY = bbox.minY + tileHeight / 2;
// Ensure assets directory exists
await vault.adapter.mkdir(this.settings.assetsPath);
console.log(`Rendering ${cols}x${rows} tiles (${cols * rows} total)`);
// Generate final save path (relative path)
const finalFileName = canvasFile.name.replace(".canvas", ".png");
const finalPath = `${this.settings.assetsPath}/${finalFileName}`;
// 创建最终 Canvas
const finalCanvas = document.createElement("canvas");
finalCanvas.width = Math.ceil(c * d * s);
finalCanvas.height = Math.ceil(u * d * s);
const ctx = finalCanvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get 2d context");
}
// Get vault full path
// @ts-ignore
const vaultBasePath = vault.adapter.basePath;
console.log("Vault base path:", vaultBasePath);
let tileIndex = 0;
const totalTiles = cols * rows;
// Build temp file absolute path
const tempFullPath = `${vaultBasePath}/${tempFileName}`;
console.log("Temp full path:", tempFullPath);
// 渲染每个分块
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
// 设置视口位置
// @ts-ignore
canvas.x = canvas.tx = startX + col * tileWidth;
// @ts-ignore
canvas.y = canvas.ty = startY + row * tileHeight;
// @ts-ignore
canvas.viewportChanged = true;
canvas.requestFrame();
// Call takeScreenshot (using absolute path)
// @ts-ignore
await canvas.takeScreenshot(tempFullPath, false);
console.log(`Screenshot saved to ${tempFullPath}`);
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise(resolve => requestAnimationFrame(resolve));
// Wait for file write to complete
await new Promise(resolve => setTimeout(resolve, 100));
// 捕获当前视口
const capturedData = await webContents.capturePage({ x: L, y: I, width: O, height: F });
// Read temp file (using relative path, returns ArrayBuffer)
const imageBuffer = await vault.adapter.readBinary(tempFileName);
console.log(`Read imageBuffer, type: ${imageBuffer?.constructor?.name}, byteLength: ${imageBuffer?.byteLength}`);
// 转换为图片并绘制到最终 Canvas
const pngData = capturedData.toPNG();
const blob = new Blob([pngData], { type: "image/png" });
const imageUrl = URL.createObjectURL(blob);
// Check if read was successful
if (!imageBuffer || imageBuffer.byteLength === 0) {
console.error("Failed to read temporary file");
return null;
const img = document.createElement("img");
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = imageUrl;
});
ctx.drawImage(img, col * O * s, row * F * s);
URL.revokeObjectURL(imageUrl);
tileIndex++;
console.log(`Rendered tile ${tileIndex}/${totalTiles}`);
}
}
// 转换为 PNG
const pngBlob = await new Promise<Blob | null>((resolve) => finalCanvas.toBlob(resolve, "image/png"));
if (!pngBlob) {
throw new Error("Failed to create PNG blob");
}
const arrayBuffer = await pngBlob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
return uint8Array;
} finally {
// 清理
// @ts-ignore
this.app.workspace.activeLeaf?.view?.contentEl?.appendChild(e);
e.removeClass("is-screenshotting");
e.removeClass("is-text-garbled");
e.setCssStyles({
top: "",
left: "",
bottom: "",
right: "",
width: "",
height: ""
});
// @ts-ignore
canvas.screenshotting = false;
await new Promise(resolve => requestAnimationFrame(resolve));
// @ts-ignore
canvas.zoomToFitQueued = true;
// @ts-ignore
canvas.onResize();
}
// Delete temp file
await vault.adapter.remove(tempFileName);
console.log("Removed temporary file");
// Write to final location (needs ArrayBuffer, not Uint8Array)
await vault.adapter.writeBinary(finalPath, imageBuffer);
console.log(`Image saved to ${finalPath}`);
// Return Uint8Array
return new Uint8Array(imageBuffer);
} catch (error) {
console.error("exportCanvasWithInternalAPI failed:", error);
console.error("Canvas render failed:", error);
return null;
}
}
// 计算 Canvas 的边界框
calculateBBox(nodes: any[]): { minX: number, minY: number, maxX: number, maxY: number } {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of nodes) {
// @ts-ignore
const x = node.x;
// @ts-ignore
const y = node.y;
// @ts-ignore
const width = node.width;
// @ts-ignore
const height = node.height;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
}
return { minX, minY, maxX, maxY };
}
// 取消选择所有节点
deselectAll(): void {
// @ts-ignore
const canvas = this.app.workspace.activeLeaf?.view?.canvas;
if (canvas) {
// @ts-ignore
canvas.deselectAll();
}
}
// 更新内容中的 Canvas 链接
updateCanvasLinksInContent(content: string, canvasFile: TFile, canvasPath: string, imageFileName: string): string {
// 获取源文件的目录路径
@@ -445,4 +553,4 @@ export default class VitePressExporterPlugin extends Plugin {
return content;
}
}
}