fix(core): address white-box review feedback

- 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>
This commit is contained in:
2026-10-20 08:37:32 +08:00
parent c6f541ed94
commit 1f7a6a2054
7 changed files with 397 additions and 132 deletions

View File

@@ -1,14 +1,23 @@
import JSZip from 'jszip';
import type { Chapter } from '../types';
export interface EpubChapterItem {
id: string;
title: string;
charOffset: number;
}
export interface ParsedEpub {
title: string;
author: string;
fullText: string;
chapters: Chapter[];
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);
@@ -39,8 +48,11 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
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() || '未知作者';
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>();
@@ -48,7 +60,6 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
const id = item.getAttribute('id');
const href = item.getAttribute('href');
if (id && href) {
// Decode URL encoding
manifestMap.set(id, decodeURIComponent(href));
}
});
@@ -62,7 +73,7 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
}
});
const chapters: Chapter[] = [];
const rawChapters: EpubChapterItem[] = [];
let fullText = '';
let chapterIndex = 1;
@@ -70,7 +81,6 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
const relativeHref = manifestMap.get(idref);
if (!relativeHref) continue;
// Resolve full path inside zip
const filePath = opfDir ? resolvePath(opfDir, relativeHref) : relativeHref;
const chapterFile = zip.file(filePath);
if (!chapterFile) continue;
@@ -87,7 +97,6 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
// Extract text content cleanly
const body = chapterDoc.body || chapterDoc.documentElement;
// Remove scripts and styles
body.querySelectorAll('script, style, svg').forEach((el) => el.remove());
const paragraphs: string[] = [];
@@ -105,10 +114,11 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
const chapterText = paragraphs.join('\n\n');
if (chapterText.trim().length > 0) {
chapters.push({
const startCharOffset = fullText.length;
rawChapters.push({
id: `chap-${chapterIndex}`,
title: chapterTitle,
startIndex: fullText.length,
charOffset: startCharOffset,
});
fullText += (fullText ? '\n\n' : '') + chapterText;
@@ -124,7 +134,7 @@ export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
title,
author,
fullText,
chapters,
rawChapters,
};
}