yeban8090_book-smith/src/services/ExportService.ts
2025-07-06 16:15:09 +08:00

483 lines
No EOL
18 KiB
TypeScript
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.

import { App, TFile } from 'obsidian';
import { Book, ChapterNode } from '../types/book';
import { TypographySettings } from '../components/TypographyView';
import * as fs from "fs/promises";
// 导出服务类
export class ExportService {
private rootPath: string;
private strategies: Record<string, ExportStrategy>;
constructor(private app: App, settings: any) {
this.rootPath = settings.defaultBookPath;
this.strategies = {
'txt': new TxtExportStrategy(this.rootPath),
'pdf': new PdfExportStrategy(this.rootPath),
};
}
async exportBook(format: string, book: Book, options?: ExportOptions): Promise<{ content: string, fileName: string }> {
const strategy = this.strategies[format];
if (!strategy) {
throw new Error(`不支持的导出格式: ${format}`);
}
const content = await strategy.export(this.app, book, options);
return {
content: content,
fileName: `${book.basic.title}.${format}`
};
}
getSupportedFormats(): string[] {
return Object.keys(this.strategies);
}
}
// 导出格式接口
export interface ExportStrategy {
export(app: App, book: Book, options?: ExportOptions): Promise<string>;
}
// 导出选项接口
export interface ExportOptions {
selectedChapters?: ChapterNode[];
htmlContent?: HTMLElement;
useTypography?: boolean;
typographySettings?: TypographySettings;
}
// TXT导出策略 - 实际实现
export class TxtExportStrategy implements ExportStrategy {
private rootPath: string;
constructor(rootPath: string) {
this.rootPath = rootPath;
}
async export(app: App, book: Book, options?: ExportOptions): Promise<string> {
try {
const content = await this.generateContent(app, book, options?.selectedChapters);
// 获取保存路径
const filePath = await this.getOutputFile(book.basic.title);
if (!filePath) return "cancelled";
// 写入文件
await fs.writeFile(filePath, content);
return filePath;
} catch (error) {
console.error('TXT导出错误:', error);
throw new Error(`TXT导出失败: ${error.message}`);
}
}
private async generateContent(app: App, book: Book, selectedChapters?: ChapterNode[]): Promise<string> {
let content = `${book.basic.title}`;
if (book.basic.subtitle) {
content += `${book.basic.subtitle}`;
}
content += `作者: ${book.basic.author.join(', ')}`;
if (book.basic.desc) {
content += `${book.basic.desc}`;
}
const chapters = selectedChapters || book.structure.tree;
content += await this.processChapters(app, book, chapters);
return content;
}
private async processChapters(app: App, book: Book, chapters: ChapterNode[], level: number = 0): Promise<string> {
let content = '';
for (const chapter of chapters) {
if (chapter.exclude) continue;
// 添加章节标题
content += `${chapter.title}`;
if (chapter.type === 'file') {
// 读取文件内容
const filePath = `${this.rootPath}/${book?.basic.title}/${chapter.path}`;
const file = app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
const fileContent = await app.vault.read(file);
// 处理Markdown语法转换为纯文本
const plainTextContent = this.convertMarkdownToPlainText(fileContent);
content += `${plainTextContent}`;
}
}
// 处理子章节
if (chapter.children && chapter.children.length > 0) {
content += await this.processChapters(app, book, chapter.children, level + 1);
}
}
return content;
}
/**
* 将Markdown格式转换为纯文本
* @param markdown Markdown格式的文本
* @returns 转换后的纯文本
*/
private convertMarkdownToPlainText(markdown: string): string {
let plainText = markdown;
// 移除标题格式 (# 标题)
plainText = plainText.replace(/^#{1,6}\s+(.+)$/gm, '$1');
// 移除加粗和斜体 (**文本** 或 *文本*)
plainText = plainText.replace(/\*\*(.+?)\*\*/g, '$1');
plainText = plainText.replace(/\*(.+?)\*/g, '$1');
plainText = plainText.replace(/__(.+?)__/g, '$1');
plainText = plainText.replace(/_(.+?)_/g, '$1');
// 移除双链接 [[链接]] 或 [[链接|显示文本]]
plainText = plainText.replace(/\[\[([^\|\]]+)\|([^\]]+)\]\]/g, '$2');
plainText = plainText.replace(/\[\[([^\]]+)\]\]/g, '$1');
// 移除普通链接 [显示文本](链接)
plainText = plainText.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');
// 移除代码块
plainText = plainText.replace(/```[\s\S]*?```/g, '');
// 移除行内代码
plainText = plainText.replace(/`([^`]+)`/g, '$1');
// 移除引用块
plainText = plainText.replace(/^>\s+(.+)$/gm, '$1');
// 移除水平分割线
plainText = plainText.replace(/^-{3,}|^\*{3,}|^_{3,}/gm, '');
// 移除任务列表标记 [x] 或 [ ]
plainText = plainText.replace(/^\s*- \[[x\s]\]\s+(.+)$/gm, '- $1');
// 处理HTML标签
plainText = plainText.replace(/<[^>]+>/g, '');
// 处理转义字符
plainText = plainText.replace(/([\\`*_{}$begin:math:display$$end:math:display$()#+\-.!])/g, '\\$1');
// 移除多余的空行(连续两个以上的换行符替换为两个)
plainText = plainText.replace(/\n{3,}/g, '\n\n');
return plainText;
}
private async getOutputFile(filename: string): Promise<string | null> {
//@ts-ignore
const result = await electron.remote.dialog.showSaveDialog({
title: "导出 TXT 文件",
defaultPath: filename + ".txt",
filters: [{ name: "TXT", extensions: ["txt"] }],
properties: ["showOverwriteConfirmation", "createDirectory"]
});
return result.canceled ? null : result.filePath;
}
}
// PDF导出策略
export class PdfExportStrategy implements ExportStrategy {
private rootPath: string;
private bookSizeMap: Record<string, { width: number; height: number }> = {
A3: { width: 841.89, height: 1190.55 }, // 297mm x 420mm
A4: { width: 595.28, height: 841.89 }, // 210mm x 297mm
A5: { width: 419.53, height: 595.28 }, // 148mm x 210mm
Legal: { width: 612, height: 1008 }, // 8.5in x 14in
Letter: { width: 612, height: 792 }, // 8.5in x 11in
Tabloid: { width: 792, height: 1224 } // 11in x 17in
};
constructor(rootPath: string) {
this.rootPath = rootPath;
}
async export(app: App, book: Book, options?: ExportOptions): Promise<string> {
try {
// 如果提供了排版后的 HTML 内容,使用 HTML 转 PDF 策略
if (options?.useTypography && options?.htmlContent && options?.typographySettings) {
return await this.exportHTML(book, options.htmlContent, options.typographySettings);
}
// 否则返回一个提示信息
return "PDF导出功能需要使用排版视图";
} catch (error) {
console.error('PDF导出错误:', error);
throw new Error(`PDF导出失败: ${error.message}`);
}
}
async exportHTML(book: Book, htmlContent: HTMLElement, typographySettings: TypographySettings): Promise<string> {
try {
//0.处理htmlContent
await PdfExportStrategy.processImages(htmlContent);
// 1. 构建样式 CSS 字符串
const style = `
body {
font-family: ${typographySettings.fontFamily || 'serif'};
font-size: ${typographySettings.fontSize || '16px'};
line-height: ${typographySettings.lineHeight || '1.75'};
margin: ${typographySettings.margin || '2cm'};
padding: 0;
box-sizing: border-box;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
.markdown-preview-view {
max-width: 720px;
margin: auto;
}
@media print {
body {
-webkit-print-color-adjust: exact;
}
.page-break {
page-break-before: always;
}
}
`;
// 2. 构建完整 HTML 页面(包含分页)
const fullHtml = `
<html>
<head>
<meta charset="utf-8">
<title>${book.basic.title}</title>
<style>${style}</style>
</head>
<body>
${htmlContent.innerHTML}
</body>
</html>
`;
// 3. 创建窗口加载 HTML 页面
//@ts-ignore
const win = new electron.remote.BrowserWindow({
show: false, // 设置为 true 可调试
width: 1024,
height: 768,
webPreferences: {
sandbox: false,
contextIsolation: false,
nodeIntegration: true,
}
});
const ready = new Promise<void>((resolve) => {
win.webContents.once("did-finish-load", resolve);
});
await win.loadURL(`data:text/html;charset=UTF-8,${encodeURIComponent(fullHtml)}`);
await ready;
// 稍等以确保样式生效
await new Promise((r) => setTimeout(r, 300));
// 4. 打印设置(自动分页)
const printOptions = {
marginsType: 1,
pageSize: typographySettings.bookSize || "A4",
printBackground: true,
landscape: false,
scale: 1.0,
displayHeaderFooter: true,
headerTemplate: `<div style="font-size:14px;text-align:center;width:100vw;"></div>`,
footerTemplate: `<div style="font-size:14px;text-align:center;width:100vw;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>`
};
// 5. 生成 PDF Buffer
const bodyPdfBuffer = await win.webContents.printToPDF(printOptions);
win.close();
// 替换第295-325行的代码
const PDFLib = await import('pdf-lib');
// 检查是否有封面图片数据
if (typographySettings.showCover && typographySettings.coverImageData) {
// 创建封面页
const coverDoc = await PDFLib.PDFDocument.create();
const page = coverDoc.addPage();
const size = this.bookSizeMap[typographySettings.bookSize || "A4"];
page.setSize(size.width, size.height);
try {
// 处理base64图片数据
const base64Data = typographySettings.coverImageData.split(',')[1]; // 移除 "data:image/xxx;base64," 前缀
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
// 根据图片类型嵌入图片
let coverImage;
if (typographySettings.coverImageData.includes('data:image/png')) {
coverImage = await coverDoc.embedPng(imageBytes);
} else if (typographySettings.coverImageData.includes('data:image/jpeg') || typographySettings.coverImageData.includes('data:image/jpg')) {
coverImage = await coverDoc.embedJpg(imageBytes);
} else {
// 默认尝试PNG格式
coverImage = await coverDoc.embedPng(imageBytes);
}
// 绘制封面图片
page.drawImage(coverImage, {
x: 0,
y: 0,
width: page.getWidth(),
height: page.getHeight(),
});
} catch (error) {
console.error('封面图片处理失败,使用默认封面:', error);
// 如果图片处理失败,绘制默认封面
page.drawRectangle({
x: 0,
y: 0,
width: page.getWidth(),
height: page.getHeight(),
color: PDFLib.rgb(0.2, 0.2, 0.2)
});
page.drawText("Book", {
x: page.getWidth() / 2 - 50,
y: page.getHeight() / 2,
size: 30,
color: PDFLib.rgb(1, 1, 1),
});
}
const coverBuffer = await coverDoc.save();
// 合并封面和内容PDF
const finalPdf = await PDFLib.PDFDocument.create();
const [coverPage] = await finalPdf.copyPages(await PDFLib.PDFDocument.load(coverBuffer), [0]);
finalPdf.addPage(coverPage);
const bodyDoc = await PDFLib.PDFDocument.load(bodyPdfBuffer);
const bodyPages = await finalPdf.copyPages(bodyDoc, bodyDoc.getPageIndices());
bodyPages.forEach(p => finalPdf.addPage(p));
const finalBuffer = await finalPdf.save();
const filePath = await this.getOutputFile(book.basic.title);
if (!filePath) return "cancelled";
await fs.writeFile(filePath, finalBuffer);
return filePath;
} else {
// 如果没有封面设置或不显示封面,创建简单的文本封面
const coverDoc = await PDFLib.PDFDocument.create();
const page = coverDoc.addPage();
const size = this.bookSizeMap[typographySettings.bookSize || "A4"];
page.setSize(size.width, size.height);
// 绘制默认封面
page.drawRectangle({
x: 0,
y: 0,
width: page.getWidth(),
height: page.getHeight(),
color: PDFLib.rgb(0.1, 0.1, 0.1)
});
// 添加书名
const titleText = "Book";
page.drawText(titleText, {
x: page.getWidth() / 2 - (titleText.length * 10),
y: page.getHeight() / 2 + 50,
size: 24,
color: PDFLib.rgb(1, 1, 1),
});
// 添加作者信息
if (book.basic.author && book.basic.author.length > 0) {
const authorText = book.basic.author.join(', ');
page.drawText(authorText, {
x: page.getWidth() / 2 - (authorText.length * 6),
y: page.getHeight() / 2 - 50,
size: 16,
color: PDFLib.rgb(0.8, 0.8, 0.8),
});
}
const coverBuffer = await coverDoc.save();
// 合并封面和内容PDF
const finalPdf = await PDFLib.PDFDocument.create();
const [coverPage] = await finalPdf.copyPages(await PDFLib.PDFDocument.load(coverBuffer), [0]);
finalPdf.addPage(coverPage);
const bodyDoc = await PDFLib.PDFDocument.load(bodyPdfBuffer);
const bodyPages = await finalPdf.copyPages(bodyDoc, bodyDoc.getPageIndices());
bodyPages.forEach(p => finalPdf.addPage(p));
const finalBuffer = await finalPdf.save();
const filePath = await this.getOutputFile(book.basic.title);
if (!filePath) return "cancelled";
await fs.writeFile(filePath, finalBuffer);
return filePath;
}
} catch (err: any) {
console.error("PDF导出错误:", err);
throw new Error(`PDF导出失败: ${err.message}`);
}
}
private static async processImages(container: HTMLElement): Promise<void> {
const images = container.querySelectorAll('img');
const imageArray = Array.from(images);
for (const img of imageArray) {
try {
const response = await fetch(img.src);
const blob = await response.blob();
const reader = new FileReader();
await new Promise((resolve, reject) => {
reader.onload = () => {
img.src = reader.result as string;
resolve(null);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (error) {
console.error('图片转换失败:', error);
}
}
}
private getHeadingTree(doc: Document) {
const headings: any[] = [];
const headingElements = doc.querySelectorAll("h1, h2, h3, h4, h5, h6");
headingElements.forEach((el) => {
const level = parseInt(el.tagName.substring(1));
const id = el.id || crypto.randomUUID();
if (!el.id) el.id = id;
headings.push({
level,
text: el.textContent,
id
});
});
return headings;
}
private async editPDF(data: Buffer, options: any): Promise<Buffer> {
return data;
}
private async getOutputFile(filename: string): Promise<string | null> {
//@ts-ignore
const result = await electron.remote.dialog.showSaveDialog({
title: "导出 PDF 文件",
defaultPath: filename + ".pdf",
filters: [{ name: "PDF", extensions: ["pdf"] }],
properties: ["showOverwriteConfirmation", "createDirectory"]
});
return result.canceled ? null : result.filePath;
}
}