update at 2025-10-08 09:18:20
This commit is contained in:
161
src/slice-image.ts
Normal file
161
src/slice-image.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/* 文件: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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user