feat: initial implementation of Focus RSVP speed reader

- Pure frontend SPA (Vite + React 19 + TypeScript + Tailwind CSS v4)
- Spritz ORP engine with absolute center crosshair gaze fixation
- Multilingual hybrid segmentation via Intl.Segmenter with punctuation attachment
- Adaptive breathing rhythm delay algorithm for natural comprehension
- IndexedDB local bookshelf, progress persistence, and client-side EPUB/TXT/MD parsing
- Curated sci-fi, philosophy, and classic speech samples
- Dark/OLED/Sepia/Slate/Nord themes and rich keyboard controls

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-20 08:28:20 +08:00
commit c6f541ed94
30 changed files with 4756 additions and 0 deletions

148
src/core/epub.ts Normal file
View File

@@ -0,0 +1,148 @@
import JSZip from 'jszip';
import type { Chapter } from '../types';
export interface ParsedEpub {
title: string;
author: string;
fullText: string;
chapters: Chapter[];
}
export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
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) {
// Decode URL encoding
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 chapters: Chapter[] = [];
let fullText = '';
let chapterIndex = 1;
for (const idref of spineItemIds) {
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;
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;
// Remove scripts and styles
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) {
chapters.push({
id: `chap-${chapterIndex}`,
title: chapterTitle,
startIndex: fullText.length,
});
fullText += (fullText ? '\n\n' : '') + chapterText;
chapterIndex++;
}
}
if (!fullText.trim()) {
throw new Error('未能从 EPUB 提取到可读文本');
}
return {
title,
author,
fullText,
chapters,
};
}
/**
* 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('/');
}