- Fix unicode surrogate pairs and emoji slicing via Array.from in ORP calculator - Add slot smoothing constraint to pivot element to prevent horizontal jitter - Implement adaptive timestamp compensation and visibilitychange auto-pause - Slim storage layer by updating only STORE_META on progress changes with memory fallback - Align EPUB chapter indices to token array indices instead of raw character offsets - Support standalone emoji tokens via Extended_Pictographic regex - Code-split JSZip via dynamic import to optimize initial bundle size Co-Authored-By: Claude Code <noreply@anthropic.com>
159 lines
4.6 KiB
TypeScript
159 lines
4.6 KiB
TypeScript
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<ParsedEpub> {
|
|
// 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<string, string>();
|
|
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('/');
|
|
}
|