export interface EpubChapterItem { id: string; title: string; charOffset: number; } export interface ParsedEpub { title: string; author: string; fullText: string; rawChapters: EpubChapterItem[]; } /** * Client-side EPUB parser with dynamic JSZip import to reduce initial bundle size. */ export async function parseEpubFile(file: File | Blob): Promise { // Dynamic import JSZip to split ~100KB off the initial load bundle const { default: JSZip } = await import('jszip'); const arrayBuffer = await file.arrayBuffer(); const zip = await JSZip.loadAsync(arrayBuffer); // 1. Locate container.xml const containerXmlFile = zip.file('META-INF/container.xml'); if (!containerXmlFile) { throw new Error('无效的 EPUB 格式:缺少 META-INF/container.xml'); } const containerXml = await containerXmlFile.async('text'); const domParser = new DOMParser(); const containerDoc = domParser.parseFromString(containerXml, 'application/xml'); const rootfileEl = containerDoc.querySelector('rootfile'); const opfPath = rootfileEl?.getAttribute('full-path'); if (!opfPath) { throw new Error('无法在 container.xml 中找到 OPF 元数据路径'); } // 2. Read OPF file const opfFile = zip.file(opfPath); if (!opfFile) { throw new Error(`无法找到 OPF 文件:${opfPath}`); } const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''; const opfXml = await opfFile.async('text'); const opfDoc = domParser.parseFromString(opfXml, 'application/xml'); // Metadata const title = opfDoc.querySelector('title, dc\\:title')?.textContent?.trim() || (file instanceof File ? file.name.replace(/\.epub$/i, '') : '未命名书籍'); const author = opfDoc.querySelector('creator, dc\\:creator')?.textContent?.trim() || '未知作者'; // Manifest: id -> href const manifestMap = new Map(); opfDoc.querySelectorAll('manifest > item').forEach((item) => { const id = item.getAttribute('id'); const href = item.getAttribute('href'); if (id && href) { manifestMap.set(id, decodeURIComponent(href)); } }); // Spine: ordered list of itemrefs const spineItemIds: string[] = []; opfDoc.querySelectorAll('spine > itemref').forEach((itemref) => { const idref = itemref.getAttribute('idref'); if (idref) { spineItemIds.push(idref); } }); const rawChapters: EpubChapterItem[] = []; let fullText = ''; let chapterIndex = 1; for (const idref of spineItemIds) { const relativeHref = manifestMap.get(idref); if (!relativeHref) continue; const filePath = opfDir ? resolvePath(opfDir, relativeHref) : relativeHref; const chapterFile = zip.file(filePath); if (!chapterFile) continue; const contentHtml = await chapterFile.async('text'); const chapterDoc = domParser.parseFromString(contentHtml, 'text/html'); // Extract title from headings or title tag const heading = chapterDoc.querySelector('h1, h2, h3, title'); let chapterTitle = heading?.textContent?.trim() || ''; if (!chapterTitle || chapterTitle.length > 50) { chapterTitle = `第 ${chapterIndex} 节`; } // Extract text content cleanly const body = chapterDoc.body || chapterDoc.documentElement; body.querySelectorAll('script, style, svg').forEach((el) => el.remove()); const paragraphs: string[] = []; const elements = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote'); if (elements.length > 0) { elements.forEach((el) => { const text = el.textContent?.trim(); if (text) paragraphs.push(text); }); } else { const text = body.textContent?.trim(); if (text) paragraphs.push(text); } const chapterText = paragraphs.join('\n\n'); if (chapterText.trim().length > 0) { const startCharOffset = fullText.length; rawChapters.push({ id: `chap-${chapterIndex}`, title: chapterTitle, charOffset: startCharOffset, }); fullText += (fullText ? '\n\n' : '') + chapterText; chapterIndex++; } } if (!fullText.trim()) { throw new Error('未能从 EPUB 提取到可读文本'); } return { title, author, fullText, rawChapters, }; } /** * Resolves a relative path against a base directory. */ function resolvePath(baseDir: string, relativePath: string): string { const parts = (baseDir + relativePath).split('/'); const resolved: string[] = []; for (const part of parts) { if (part === '.' || part === '') continue; if (part === '..') { resolved.pop(); } else { resolved.push(part); } } return resolved.join('/'); }