c5c0b7329b
Add ExportMatrixService for txt/md/docx/pdf, extend ExportModal, cockpit outline tasks, focus ambient sound, and E2E-PDF-01 with 134 passing unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
218 lines
7.0 KiB
TypeScript
218 lines
7.0 KiB
TypeScript
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<ExportMatrixParams['format'], string> = {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<Buffer> {
|
|
const docx = await import('docx')
|
|
const {
|
|
Document,
|
|
Packer,
|
|
Paragraph,
|
|
TextRun,
|
|
HeadingLevel,
|
|
TableOfContents,
|
|
AlignmentType,
|
|
PageBreak
|
|
} = docx
|
|
|
|
const children: InstanceType<typeof Paragraph>[] = [
|
|
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 ? '<p class="ai-badge">[AI生成内容]</p>' : ''
|
|
const body = block.paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join('')
|
|
const notes =
|
|
block.annotations.length > 0
|
|
? `<div class="annotations"><strong>批注</strong><ul>${block.annotations.map((a) => `<li>${escapeHtml(a)}</li>`).join('')}</ul></div>`
|
|
: ''
|
|
return `<section class="chapter"><h2>${idx + 1}. ${escapeHtml(block.title)}</h2>${ai}${body}${notes}</section>`
|
|
})
|
|
.join('')
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<style>
|
|
@page { margin: 2cm 2cm 2.5cm 2cm; }
|
|
body { font-family: 'Noto Serif SC', Georgia, serif; font-size: 12pt; line-height: 1.8; color: #222; }
|
|
.print-header { font-size: 9pt; color: #666; border-bottom: 1px solid #ccc; padding-bottom: 6px; margin-bottom: 24px; display: flex; justify-content: space-between; }
|
|
.chapter { page-break-before: always; }
|
|
.chapter:first-of-type { page-break-before: auto; }
|
|
h2 { font-size: 16pt; margin: 0 0 12px; }
|
|
.ai-badge { font-size: 9pt; color: #888; }
|
|
.annotations { font-size: 9pt; color: #555; margin-top: 16px; border-top: 1px dashed #ccc; padding-top: 8px; }
|
|
.print-footer { font-size: 9pt; color: #666; margin-top: 32px; border-top: 1px solid #ccc; padding-top: 6px; display: flex; justify-content: space-between; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="print-header">
|
|
<span>${escapeHtml(bookName)}</span>
|
|
<span>笔临导出</span>
|
|
<span></span>
|
|
</div>
|
|
${chaptersHtml}
|
|
<div class="print-footer">
|
|
<span>${escapeHtml(penName)}</span>
|
|
<span>${exportedAt}</span>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
private async exportPdf(
|
|
blocks: ExportChapterBlock[],
|
|
bookName: string,
|
|
penName: string,
|
|
destPath: string
|
|
): Promise<void> {
|
|
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<void>((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, '>')
|
|
.replace(/"/g, '"')
|
|
}
|