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:
148
src/core/epub.ts
Normal file
148
src/core/epub.ts
Normal 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('/');
|
||||
}
|
||||
74
src/core/orp.ts
Normal file
74
src/core/orp.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Optimal Recognition Point (ORP) Calculation for Spritz RSVP Reading.
|
||||
* Fixates the eye at ~30%-35% into the word where human ocular fixation naturally rests.
|
||||
*/
|
||||
|
||||
const CJK_REGEX = /[一-龥-ヿ가-]/;
|
||||
|
||||
export function isCJKChar(char: string): boolean {
|
||||
return CJK_REGEX.test(char);
|
||||
}
|
||||
|
||||
export function isCJKWord(word: string): boolean {
|
||||
return CJK_REGEX.test(word);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the 0-based index of the ORP character in cleanWord.
|
||||
*/
|
||||
export function calculateOrpIndex(cleanWord: string): number {
|
||||
const len = cleanWord.length;
|
||||
if (len <= 1) return 0;
|
||||
|
||||
if (isCJKWord(cleanWord)) {
|
||||
// CJK characters have high semantic density per glyph
|
||||
if (len === 2) return 0;
|
||||
if (len <= 4) return 1;
|
||||
if (len <= 6) return 2;
|
||||
return Math.min(len - 1, Math.floor((len - 1) * 0.35));
|
||||
}
|
||||
|
||||
// Standard Spritz ORP Table for alphabetic words
|
||||
if (len <= 5) return 1;
|
||||
if (len <= 9) return 2;
|
||||
if (len <= 13) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
export interface SplitWord {
|
||||
prefix: string;
|
||||
pivot: string;
|
||||
suffix: string;
|
||||
orpIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a token into prefix, pivot (ORP character), and suffix.
|
||||
* Handles leading punctuation (e.g. "“", "《", "(") and trailing punctuation.
|
||||
*/
|
||||
export function splitOrp(
|
||||
coreWord: string,
|
||||
leadingPunct: string = '',
|
||||
trailingPunct: string = ''
|
||||
): SplitWord {
|
||||
if (!coreWord) {
|
||||
return {
|
||||
prefix: leadingPunct,
|
||||
pivot: '',
|
||||
suffix: trailingPunct,
|
||||
orpIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const orpIndex = calculateOrpIndex(coreWord);
|
||||
const prefix = leadingPunct + coreWord.slice(0, orpIndex);
|
||||
const pivot = coreWord[orpIndex] || '';
|
||||
const suffix = coreWord.slice(orpIndex + 1) + trailingPunct;
|
||||
|
||||
return {
|
||||
prefix,
|
||||
pivot,
|
||||
suffix,
|
||||
orpIndex,
|
||||
};
|
||||
}
|
||||
73
src/core/timing.ts
Normal file
73
src/core/timing.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ReaderSettings, Token } from '../types';
|
||||
import { isCJKWord } from './orp';
|
||||
|
||||
/**
|
||||
* Calculates the display duration (in milliseconds) for a single token,
|
||||
* applying the physiological breathing rhythm algorithm.
|
||||
*/
|
||||
export function calculateTokenDelay(
|
||||
token: Token,
|
||||
settings: ReaderSettings
|
||||
): number {
|
||||
const baseDelay = Math.max(20, Math.round(60000 / Math.max(50, settings.wpm)));
|
||||
|
||||
if (!settings.breathingPunctuation) {
|
||||
return baseDelay;
|
||||
}
|
||||
|
||||
let multiplier = 1.0;
|
||||
|
||||
if (token.isParagraphEnd) {
|
||||
multiplier = settings.paragraphEndMultiplier ?? 2.5;
|
||||
} else if (token.isSentenceEnd) {
|
||||
multiplier = settings.sentenceEndMultiplier ?? 2.2;
|
||||
} else if (token.isClauseEnd) {
|
||||
multiplier = settings.clauseEndMultiplier ?? 1.4;
|
||||
} else if (token.isLongWord) {
|
||||
multiplier = 1.2;
|
||||
} else if (token.cleanWord.length <= 2 && !isCJKWord(token.cleanWord)) {
|
||||
// Short function words (e.g. "a", "of", "in") flash slightly faster
|
||||
multiplier = 0.9;
|
||||
}
|
||||
|
||||
// Minimum duration protection (never flash below 20ms)
|
||||
return Math.max(20, Math.round(baseDelay * multiplier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the remaining reading time in seconds from current index to the end.
|
||||
*/
|
||||
export function estimateRemainingSeconds(
|
||||
tokens: Token[],
|
||||
currentIndex: number,
|
||||
settings: ReaderSettings
|
||||
): number {
|
||||
if (!tokens || currentIndex >= tokens.length) return 0;
|
||||
|
||||
const remainingTokens = tokens.length - currentIndex;
|
||||
// Fast approximate calculation considering ~15% punctuation overhead
|
||||
const baseDelay = 60000 / Math.max(50, settings.wpm);
|
||||
const factor = settings.breathingPunctuation ? 1.15 : 1.0;
|
||||
const totalMs = remainingTokens * baseDelay * factor;
|
||||
|
||||
return Math.ceil(totalMs / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats seconds into human-readable duration (e.g. "3分20秒" or "45秒" or "1小时12分").
|
||||
*/
|
||||
export function formatDuration(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return '0秒';
|
||||
if (totalSeconds < 60) return `${totalSeconds}秒`;
|
||||
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes < 60) {
|
||||
return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分钟`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMinutes = minutes % 60;
|
||||
return remMinutes > 0 ? `${hours}小时${remMinutes}分` : `${hours}小时`;
|
||||
}
|
||||
238
src/core/tokenizer.ts
Normal file
238
src/core/tokenizer.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Chapter, Token } from '../types';
|
||||
import { isCJKWord, splitOrp } from './orp';
|
||||
|
||||
// Leading brackets and quotes that should attach to the beginning of the next word
|
||||
const LEADING_PUNCT_REGEX = /^[“‘「『《〈(([<{<"'`]/;
|
||||
|
||||
// Sentence-ending punctuation
|
||||
const SENTENCE_PUNCT_REGEX = /[.!?。!?…\n]/;
|
||||
|
||||
// Clause/pause punctuation
|
||||
const CLAUSE_PUNCT_REGEX = /[,;:,;:、—–]/;
|
||||
|
||||
// Chapter detection regex patterns
|
||||
const CHAPTER_REGEXES = [
|
||||
/^(#{1,3}\s+.+)$/m, // # Markdown header
|
||||
/^(第[0-9一二三四五六七八九十百千万]+[章回卷节篇部集].*)$/m, // 第X章 ...
|
||||
/^(Chapter\s+[0-9IVXLCDM]+.*)$/im, // Chapter X ...
|
||||
/^(Part\s+[0-9IVXLCDM]+.*)$/im, // Part X ...
|
||||
/^(Book\s+[0-9IVXLCDM]+.*)$/im, // Book X ...
|
||||
/^(Act\s+[0-9IVXLCDM]+.*)$/im, // Act X ...
|
||||
];
|
||||
|
||||
export interface TokenizeResult {
|
||||
tokens: Token[];
|
||||
chapters: Chapter[];
|
||||
}
|
||||
|
||||
export function tokenizeText(
|
||||
rawText: string,
|
||||
existingChapters?: Chapter[]
|
||||
): TokenizeResult {
|
||||
if (!rawText || !rawText.trim()) {
|
||||
return { tokens: [], chapters: [] };
|
||||
}
|
||||
|
||||
const chapters: Chapter[] = existingChapters ? [...existingChapters] : [];
|
||||
const autoDetectChapters = !existingChapters || existingChapters.length === 0;
|
||||
|
||||
let segmenter: Intl.Segmenter | null = null;
|
||||
try {
|
||||
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
|
||||
segmenter = new Intl.Segmenter(['zh', 'en', 'ja', 'ko'], {
|
||||
granularity: 'word',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Intl.Segmenter not available, fallback to basic regex', err);
|
||||
}
|
||||
|
||||
// Pre-process chapter headers if auto detecting from plain text
|
||||
const detectedChapterPositions: { line: string; index: number }[] = [];
|
||||
if (autoDetectChapters) {
|
||||
let charIndex = 0;
|
||||
const lines = rawText.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.length > 0 && trimmedLine.length < 60) {
|
||||
for (const regex of CHAPTER_REGEXES) {
|
||||
if (regex.test(trimmedLine)) {
|
||||
const cleanTitle = trimmedLine.replace(/^#+\s*/, '').trim();
|
||||
detectedChapterPositions.push({
|
||||
line: cleanTitle,
|
||||
index: charIndex,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
charIndex += line.length + 1; // +1 for the newline
|
||||
}
|
||||
}
|
||||
|
||||
const tokens: Token[] = [];
|
||||
let pendingLeading = '';
|
||||
let newlineCount = 0;
|
||||
let nextChapterIdx = 0;
|
||||
let currentChapterIndex = 0;
|
||||
|
||||
if (segmenter) {
|
||||
const segments = segmenter.segment(rawText);
|
||||
|
||||
for (const seg of segments) {
|
||||
const { segment, isWordLike, index } = seg;
|
||||
|
||||
// Check if we reached a detected chapter boundary
|
||||
if (
|
||||
autoDetectChapters &&
|
||||
nextChapterIdx < detectedChapterPositions.length &&
|
||||
index >= detectedChapterPositions[nextChapterIdx].index
|
||||
) {
|
||||
const chapInfo = detectedChapterPositions[nextChapterIdx];
|
||||
currentChapterIndex = chapters.length;
|
||||
chapters.push({
|
||||
id: `chap-${chapters.length + 1}`,
|
||||
title: chapInfo.line,
|
||||
startIndex: tokens.length,
|
||||
});
|
||||
nextChapterIdx++;
|
||||
if (tokens.length > 0) {
|
||||
tokens[tokens.length - 1].isParagraphEnd = true;
|
||||
tokens[tokens.length - 1].isSentenceEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isWordLike) {
|
||||
if (segment.includes('\n')) {
|
||||
newlineCount += (segment.match(/\n/g) || []).length;
|
||||
}
|
||||
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
// Whitespace only
|
||||
if (newlineCount >= 2 && tokens.length > 0) {
|
||||
tokens[tokens.length - 1].isParagraphEnd = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LEADING_PUNCT_REGEX.test(trimmed)) {
|
||||
// Attaches to the NEXT token's prefix
|
||||
pendingLeading += trimmed;
|
||||
} else {
|
||||
// Trailing punctuation attached to the PREVIOUS token
|
||||
if (tokens.length > 0) {
|
||||
const prev = tokens[tokens.length - 1];
|
||||
prev.text += trimmed;
|
||||
prev.suffix += trimmed;
|
||||
if (SENTENCE_PUNCT_REGEX.test(trimmed)) {
|
||||
prev.isSentenceEnd = true;
|
||||
}
|
||||
if (CLAUSE_PUNCT_REGEX.test(trimmed)) {
|
||||
prev.isClauseEnd = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newlineCount >= 2 && tokens.length > 0) {
|
||||
tokens[tokens.length - 1].isParagraphEnd = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word segment
|
||||
const coreWord = segment.trim();
|
||||
if (!coreWord) continue;
|
||||
|
||||
const isLong = isCJKWord(coreWord)
|
||||
? coreWord.length >= 4
|
||||
: coreWord.length > 8;
|
||||
|
||||
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
||||
coreWord,
|
||||
pendingLeading,
|
||||
''
|
||||
);
|
||||
|
||||
const isParagraphBreak = newlineCount >= 2;
|
||||
|
||||
tokens.push({
|
||||
id: tokens.length,
|
||||
text: prefix + pivot + suffix,
|
||||
cleanWord: coreWord,
|
||||
prefix,
|
||||
pivot,
|
||||
suffix,
|
||||
orpIndex,
|
||||
isParagraphEnd: isParagraphBreak,
|
||||
isSentenceEnd: false,
|
||||
isClauseEnd: false,
|
||||
isLongWord: isLong,
|
||||
chapterIndex: currentChapterIndex,
|
||||
});
|
||||
|
||||
pendingLeading = '';
|
||||
newlineCount = 0;
|
||||
}
|
||||
} else {
|
||||
// Regex fallback for environments without Intl.Segmenter
|
||||
const wordPattern = /[一-龥]|[a-zA-Z0-9_À-ɏ]+|[^\s\w]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = wordPattern.exec(rawText)) !== null) {
|
||||
const str = match[0];
|
||||
if (/[一-龥a-zA-Z0-9]/.test(str)) {
|
||||
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
||||
str,
|
||||
pendingLeading,
|
||||
''
|
||||
);
|
||||
tokens.push({
|
||||
id: tokens.length,
|
||||
text: prefix + pivot + suffix,
|
||||
cleanWord: str,
|
||||
prefix,
|
||||
pivot,
|
||||
suffix,
|
||||
orpIndex,
|
||||
isParagraphEnd: newlineCount >= 2,
|
||||
isSentenceEnd: false,
|
||||
isClauseEnd: false,
|
||||
isLongWord: str.length > 8,
|
||||
chapterIndex: currentChapterIndex,
|
||||
});
|
||||
pendingLeading = '';
|
||||
newlineCount = 0;
|
||||
} else if (LEADING_PUNCT_REGEX.test(str)) {
|
||||
pendingLeading += str;
|
||||
} else {
|
||||
if (tokens.length > 0) {
|
||||
const prev = tokens[tokens.length - 1];
|
||||
prev.text += str;
|
||||
prev.suffix += str;
|
||||
if (SENTENCE_PUNCT_REGEX.test(str)) prev.isSentenceEnd = true;
|
||||
if (CLAUSE_PUNCT_REGEX.test(str)) prev.isClauseEnd = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no chapters detected at all, supply default single chapter
|
||||
if (chapters.length === 0 && tokens.length > 0) {
|
||||
chapters.push({
|
||||
id: 'chap-1',
|
||||
title: '全文',
|
||||
startIndex: 0,
|
||||
tokenCount: tokens.length,
|
||||
});
|
||||
} else {
|
||||
// Fill in chapter token counts
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const nextStart =
|
||||
i + 1 < chapters.length ? chapters[i + 1].startIndex : tokens.length;
|
||||
chapters[i].tokenCount = Math.max(0, nextStart - chapters[i].startIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return { tokens, chapters };
|
||||
}
|
||||
Reference in New Issue
Block a user