162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
/* 文件:slice-image.ts — 预览页面切图功能:将渲染完的 HTML 页面转为长图,再按比例裁剪为多张 PNG 图片。 */
|
||
|
||
import { toPng } from 'html-to-image';
|
||
import { Notice, TFile } from 'obsidian';
|
||
import { NMPSettings } from './settings';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
|
||
/**
|
||
* 解析横竖比例字符串(如 "3:4")为数值
|
||
*/
|
||
function parseAspectRatio(ratio: string): { width: number; height: number } {
|
||
const parts = ratio.split(':').map(p => parseFloat(p.trim()));
|
||
if (parts.length === 2 && parts[0] > 0 && parts[1] > 0) {
|
||
return { width: parts[0], height: parts[1] };
|
||
}
|
||
// 默认 3:4
|
||
return { width: 3, height: 4 };
|
||
}
|
||
|
||
/**
|
||
* 从 frontmatter 获取 slug,若不存在则使用文件名(去除扩展名)
|
||
*/
|
||
function getSlugFromFile(file: TFile, app: any): string {
|
||
const cache = app.metadataCache.getFileCache(file);
|
||
if (cache?.frontmatter?.slug) {
|
||
return String(cache.frontmatter.slug).trim();
|
||
}
|
||
return file.basename;
|
||
}
|
||
|
||
/**
|
||
* 确保目录存在
|
||
*/
|
||
function ensureDir(dirPath: string) {
|
||
if (!fs.existsSync(dirPath)) {
|
||
fs.mkdirSync(dirPath, { recursive: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将 base64 dataURL 转为 Buffer
|
||
*/
|
||
function dataURLToBuffer(dataURL: string): Buffer {
|
||
const base64 = dataURL.split(',')[1];
|
||
return Buffer.from(base64, 'base64');
|
||
}
|
||
|
||
/**
|
||
* 切图主函数
|
||
* @param articleElement 预览文章的 HTML 元素(#article-section)
|
||
* @param file 当前文件
|
||
* @param app Obsidian App 实例
|
||
*/
|
||
export async function sliceArticleImage(articleElement: HTMLElement, file: TFile, app: any) {
|
||
const settings = NMPSettings.getInstance();
|
||
const { sliceImageSavePath, sliceImageWidth, sliceImageAspectRatio } = settings;
|
||
|
||
// 解析比例
|
||
const ratio = parseAspectRatio(sliceImageAspectRatio);
|
||
const sliceHeight = Math.round((sliceImageWidth * ratio.height) / ratio.width);
|
||
|
||
// 获取 slug
|
||
const slug = getSlugFromFile(file, app);
|
||
|
||
new Notice(`开始切图:${slug},宽度=${sliceImageWidth},比例=${sliceImageAspectRatio}`);
|
||
|
||
try {
|
||
// 1. 保存原始样式
|
||
const originalWidth = articleElement.style.width;
|
||
const originalMaxWidth = articleElement.style.maxWidth;
|
||
const originalMinWidth = articleElement.style.minWidth;
|
||
|
||
// 2. 临时设置为目标宽度进行渲染
|
||
articleElement.style.width = `${sliceImageWidth}px`;
|
||
articleElement.style.maxWidth = `${sliceImageWidth}px`;
|
||
articleElement.style.minWidth = `${sliceImageWidth}px`;
|
||
|
||
// 等待样式生效和重排
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
new Notice(`设置渲染宽度: ${sliceImageWidth}px`);
|
||
|
||
// 3. 生成长图 - 使用实际渲染宽度
|
||
new Notice('正在生成长图...');
|
||
const longImageDataURL = await toPng(articleElement, {
|
||
width: sliceImageWidth,
|
||
pixelRatio: 1,
|
||
cacheBust: true,
|
||
});
|
||
|
||
// 4. 恢复原始样式
|
||
articleElement.style.width = originalWidth;
|
||
articleElement.style.maxWidth = originalMaxWidth;
|
||
articleElement.style.minWidth = originalMinWidth;
|
||
|
||
// 5. 创建临时 Image 对象以获取长图实际高度
|
||
const img = new Image();
|
||
await new Promise<void>((resolve, reject) => {
|
||
img.onload = () => resolve();
|
||
img.onerror = reject;
|
||
img.src = longImageDataURL;
|
||
});
|
||
|
||
const fullHeight = img.height;
|
||
const fullWidth = img.width;
|
||
|
||
new Notice(`长图生成完成:${fullWidth}x${fullHeight}px`);
|
||
|
||
// 3. 保存完整长图
|
||
ensureDir(sliceImageSavePath);
|
||
const longImagePath = path.join(sliceImageSavePath, `${slug}.png`);
|
||
const longImageBuffer = dataURLToBuffer(longImageDataURL);
|
||
fs.writeFileSync(longImagePath, new Uint8Array(longImageBuffer));
|
||
new Notice(`长图已保存:${longImagePath}`);
|
||
|
||
// 4. 计算需要切多少片
|
||
const sliceCount = Math.ceil(fullHeight / sliceHeight);
|
||
new Notice(`开始切图:共 ${sliceCount} 张,每张 ${sliceImageWidth}x${sliceHeight}px`);
|
||
|
||
// 5. 使用 Canvas 裁剪
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = sliceImageWidth;
|
||
canvas.height = sliceHeight;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
if (!ctx) {
|
||
throw new Error('无法创建 Canvas 上下文');
|
||
}
|
||
|
||
for (let i = 0; i < sliceCount; i++) {
|
||
const yOffset = i * sliceHeight;
|
||
const actualHeight = Math.min(sliceHeight, fullHeight - yOffset);
|
||
|
||
// 清空画布(处理最后一张可能不足高度的情况,用白色填充)
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, sliceImageWidth, sliceHeight);
|
||
|
||
// 绘制裁剪区域
|
||
ctx.drawImage(
|
||
img,
|
||
0, yOffset, fullWidth, actualHeight, // 源区域
|
||
0, 0, sliceImageWidth, actualHeight // 目标区域
|
||
);
|
||
|
||
// 导出为 PNG
|
||
const sliceDataURL = canvas.toDataURL('image/png');
|
||
const sliceBuffer = dataURLToBuffer(sliceDataURL);
|
||
const sliceFilename = `${slug}_${i + 1}.png`;
|
||
const slicePath = path.join(sliceImageSavePath, sliceFilename);
|
||
fs.writeFileSync(slicePath, new Uint8Array(sliceBuffer));
|
||
|
||
new Notice(`已保存:${sliceFilename}`);
|
||
}
|
||
|
||
new Notice(`✅ 切图完成!共 ${sliceCount} 张图片,保存在:${sliceImageSavePath}`);
|
||
} catch (error) {
|
||
console.error('切图失败:', error);
|
||
new Notice(`❌ 切图失败:${error.message}`);
|
||
}
|
||
}
|