import { BrowserWindow, dialog } from 'electron' import { writeFileSync } from 'fs' import type { ExportMatrixParams } from '../../shared/export-matrix' import type { ExportChapterBlock } from '../lib/export-content' import { buildExportBlocks, buildMdExport, buildTxtExport, resolveExportChapters } from '../lib/export-content' import type { BookRegistryService } from './book-registry' import type { GlobalSettingsService } from './global-settings' const FORMAT_EXT: Record = { txt: 'txt', md: 'md', docx: 'docx', pdf: 'pdf' } export class ExportMatrixService { constructor( private registry: BookRegistryService, private settings: GlobalSettingsService ) {} collectBlocks(params: ExportMatrixParams): ExportChapterBlock[] { const db = this.registry.getDb(params.bookId) const chapters = resolveExportChapters(db, params.scope, params.scopeId) if (chapters.length === 0) throw new Error('No chapters to export') return buildExportBlocks(db, chapters, params.options) } async exportToPath(params: ExportMatrixParams, destPath: string): Promise { const blocks = this.collectBlocks(params) const book = this.registry.list().find((b) => b.id === params.bookId) const bookName = book?.name ?? 'export' const { penName } = this.settings.get() switch (params.format) { case 'txt': writeFileSync(destPath, buildTxtExport(blocks), 'utf8') break case 'md': writeFileSync(destPath, buildMdExport(blocks), 'utf8') break case 'docx': writeFileSync(destPath, await this.buildDocxBuffer(blocks, bookName)) break case 'pdf': await this.exportPdf(blocks, bookName, penName, destPath) break default: throw new Error(`Unsupported format: ${params.format}`) } } async pickAndExport(params: ExportMatrixParams): Promise { const ext = FORMAT_EXT[params.format] let destPath: string | null = null if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_PDF_SAVE) { destPath = process.env.BILIN_E2E_PDF_SAVE } else { const book = this.registry.list().find((b) => b.id === params.bookId) const defaultPath = `${book?.name ?? 'export'}.${ext}` const { canceled, filePath } = await dialog.showSaveDialog({ defaultPath, filters: [{ name: ext.toUpperCase(), extensions: [ext] }] }) if (canceled || !filePath) return null destPath = filePath } await this.exportToPath(params, destPath) return destPath } private async buildDocxBuffer(blocks: ExportChapterBlock[], bookName: string): Promise { const docx = await import('docx') const { Document, Packer, Paragraph, TextRun, HeadingLevel, TableOfContents, AlignmentType, PageBreak } = docx const children: InstanceType[] = [ new Paragraph({ text: bookName, heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }), new TableOfContents('目录', { hyperlink: true, headingStyleRange: '1-1' }), new Paragraph({ children: [new PageBreak()] }) ] for (const block of blocks) { children.push( new Paragraph({ text: block.title, heading: HeadingLevel.HEADING_1 }) ) if (block.hasAiContent) { children.push( new Paragraph({ children: [new TextRun({ text: '[AI生成内容]', italics: true, color: '888888' })] }) ) } for (const p of block.paragraphs) { children.push(new Paragraph({ children: [new TextRun(p)] })) } if (block.annotations.length > 0) { children.push( new Paragraph({ children: [new TextRun({ text: '批注', bold: true })] }) ) for (const note of block.annotations) { children.push( new Paragraph({ children: [new TextRun({ text: `• ${note}`, size: 20 })] }) ) } } } const doc = new Document({ features: { updateFields: true }, sections: [{ children }] }) return Packer.toBuffer(doc) } private buildPdfHtml(blocks: ExportChapterBlock[], bookName: string, penName: string): string { const exportedAt = new Date().toLocaleDateString('zh-CN') const chaptersHtml = blocks .map((block, idx) => { const ai = block.hasAiContent ? '

[AI生成内容]

' : '' const body = block.paragraphs.map((p) => `

${escapeHtml(p)}

`).join('') const notes = block.annotations.length > 0 ? `
批注
    ${block.annotations.map((a) => `
  • ${escapeHtml(a)}
  • `).join('')}
` : '' return `

${idx + 1}. ${escapeHtml(block.title)}

${ai}${body}${notes}
` }) .join('') return ` ${chaptersHtml} ` } private async exportPdf( blocks: ExportChapterBlock[], bookName: string, penName: string, destPath: string ): Promise { const html = this.buildPdfHtml(blocks, bookName, penName) const win = new BrowserWindow({ show: false, webPreferences: { offscreen: true } }) try { await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) await new Promise((resolve) => setTimeout(resolve, 300)) const pdf = await win.webContents.printToPDF({ printBackground: true, pageSize: 'A4', margins: { top: 0.5, bottom: 0.5, left: 0.5, right: 0.5 } }) writeFileSync(destPath, pdf) } finally { win.destroy() } } } function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') }