Compare commits
10 Commits
072a47378d
...
88afb217c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88afb217c6 | ||
|
|
4a1ac384a1 | ||
|
|
d1c3a9058a | ||
|
|
51356f7258 | ||
|
|
17fbe7efbb | ||
|
|
5bf11256b7 | ||
|
|
103f767e53 | ||
|
|
46fe8c4da5 | ||
|
|
79d2fcd75c | ||
|
|
5f66478c26 |
70
README.md
Normal file
70
README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# ePub Reader
|
||||
|
||||
基于 Rust + egui 的 Windows ePub 阅读器,单 exe 文件,无需额外依赖。
|
||||
|
||||
## 截图
|
||||
|
||||
启动页 → 选择 ePub 文件 → Kindle 风格阅读界面。
|
||||
|
||||
## 功能
|
||||
|
||||
- 打开 .epub 文件(原生文件对话框)
|
||||
- Kindle 风格阅读界面(顶部工具栏 + 中央正文 + 底部进度条)
|
||||
- 点击左右区域翻页(左 30% 上一页、右 30% 下一页)
|
||||
- 键盘 ← → 翻页
|
||||
- 目录侧栏(可展开/收起)
|
||||
- 字体大小调节(A⁺ / A⁻)
|
||||
- 日间/夜间模式切换
|
||||
- 底部进度条可拖动跳转
|
||||
- 自动保存阅读位置
|
||||
- 最近文件列表
|
||||
- 设置持久化(settings.json)
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Windows 7+
|
||||
- 无需安装任何运行时
|
||||
|
||||
## 下载
|
||||
|
||||
从 [Releases](https://github.com/xiaji/epub-read/releases) 下载最新版本 `epub-read.exe`,直接运行。
|
||||
|
||||
## 构建
|
||||
|
||||
### 环境要求
|
||||
|
||||
- MSYS2 + MinGW64
|
||||
- Rust 工具链 `x86_64-pc-windows-gnu`
|
||||
|
||||
```bash
|
||||
# 在 MINGW64 终端中
|
||||
cargo build --release
|
||||
# 输出: target/release/epub-read.exe
|
||||
```
|
||||
|
||||
### 静态链接说明
|
||||
|
||||
已配置 `.cargo/config.toml` 启用 `+crt-static`,编译出的 exe 不依赖 MinGW 运行时 DLL。
|
||||
内嵌 Noto Sans SC 中文字体(约 17MB),确保中文正常显示。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 选型 |
|
||||
|---|---|
|
||||
| GUI | [egui](https://github.com/emilk/egui) / [eframe](https://crates.io/crates/eframe) |
|
||||
| ePub 解析 | [epub](https://crates.io/crates/epub) |
|
||||
| 文件对话框 | [rfd](https://crates.io/crates/rfd) |
|
||||
| 序列化 | [serde](https://crates.io/crates/serde) / [serde_json](https://crates.io/crates/serde_json) |
|
||||
| 中文字体 | [Noto Sans SC](https://fonts.google.com/specimen/Noto+Sans+SC) |
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
cargo test # 运行测试(24 个)
|
||||
cargo check # 编译检查
|
||||
cargo run # 调试运行
|
||||
```
|
||||
|
||||
## 许可
|
||||
|
||||
MIT
|
||||
1525
docs/superpowers/plans/2026-05-13-epub-reader.md
Normal file
1525
docs/superpowers/plans/2026-05-13-epub-reader.md
Normal file
File diff suppressed because it is too large
Load Diff
162
docs/superpowers/specs/2026-05-13-epub-reader-design.md
Normal file
162
docs/superpowers/specs/2026-05-13-epub-reader-design.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# ePub Reader — Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
A lightweight ePub reader desktop application built with Rust + egui, compiled as a single Windows executable (x86_64-pc-windows-gnu / MSYS2+MinGW). No console window. Built-in Chinese font support. Kindle-like reading experience with medium feature set.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice |
|
||||
|---|---|
|
||||
| GUI Framework | `eframe` (egui official framework) |
|
||||
| Rendering Backend | `wgpu` |
|
||||
| ePub Parsing | `epub-rs` |
|
||||
| Font | Bundled Noto Sans SC (or similar open-source Chinese font) |
|
||||
| Build Target | `x86_64-pc-windows-gnu` |
|
||||
| Binary | Single `.exe`, `#![windows_subsystem = "windows"]` |
|
||||
|
||||
## Core Data Model
|
||||
|
||||
```rust
|
||||
struct AppState {
|
||||
book: Option<Book>,
|
||||
current_section: usize,
|
||||
current_page: usize,
|
||||
settings: Settings,
|
||||
bookmarks: Vec<Bookmark>,
|
||||
sidebar_open: bool,
|
||||
}
|
||||
|
||||
struct Book {
|
||||
title: String,
|
||||
author: String,
|
||||
cover: Option<Vec<u8>>,
|
||||
sections: Vec<Section>,
|
||||
toc: Vec<TocEntry>,
|
||||
}
|
||||
|
||||
struct Section {
|
||||
title: String,
|
||||
content: String, // plain text (HTML tags stripped)
|
||||
pages: Vec<usize>, // character offsets for page boundaries
|
||||
}
|
||||
|
||||
struct Settings {
|
||||
font_size: f32, // default 20.0
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
enum Theme { Light, Dark }
|
||||
|
||||
struct Bookmark {
|
||||
section: usize,
|
||||
page: usize,
|
||||
label: String,
|
||||
timestamp: i64,
|
||||
}
|
||||
|
||||
struct TocEntry {
|
||||
label: String,
|
||||
section: usize,
|
||||
children: Vec<TocEntry>,
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ eframe::App │
|
||||
│ ┌──────────┐ ┌───────────────────────┐ │
|
||||
│ │ WelcomePg │ │ ReadingView │ │
|
||||
│ │ (no book) │ │ ├─ TopToolbar │ │
|
||||
│ │ ──────── │ │ ├─ Sidebar (TOC) │ │
|
||||
│ │ open btn │ │ ├─ TextArea │ │
|
||||
│ │ recents │ │ └─ BottomProgressBar │ │
|
||||
│ └──────────┘ └───────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ AppState │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## UI Layout
|
||||
|
||||
### Welcome Screen (no book open)
|
||||
- Centered "Open ePub File" button
|
||||
- Recently opened books list (read from settings file)
|
||||
|
||||
### Reading Screen (Kindle-like)
|
||||
- **Top toolbar**: back button, book title, font size controls, menu, bookmark toggle, theme toggle
|
||||
- **Left sidebar** (collapsible): table of contents tree
|
||||
- **Center**: text content area with pagination
|
||||
- Click left 30% → prev page, right 30% → next page, center 40% → toggle toolbar visibility
|
||||
- Keyboard: ← → for prev/next page
|
||||
- **Bottom bar**: current chapter name, progress slider, page number indicator
|
||||
|
||||
## Features (Medium Scope)
|
||||
|
||||
### Core
|
||||
1. Open `.epub` file via native file dialog
|
||||
2. Parse ePub (OPF manifest, NCX/TOC, Spine)
|
||||
3. Render chapter content with pagination
|
||||
4. Next/previous page navigation (click & keyboard)
|
||||
5. Table of contents sidebar with chapter navigation
|
||||
6. Remember last reading position (bookmark per file)
|
||||
|
||||
### Settings & Display
|
||||
7. Font size adjustment (A⁺/A⁻)
|
||||
8. Light/Dark theme toggle
|
||||
9. Chinese font support (bundled Noto Sans SC)
|
||||
|
||||
### Bookmarks
|
||||
10. Add/remove bookmarks at current page
|
||||
11. Bookmark list view
|
||||
|
||||
### Persistence
|
||||
12. `settings.json` saves: font_size, theme, recent files, reading positions, bookmarks
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User clicks "Open" → native file dialog → select `.epub`
|
||||
2. `EpubLoader` reads zip, extracts OPF metadata, reads spine order, parses NCX for TOC
|
||||
3. For each spine item: extract HTML content, strip tags → plain text stored in `Section.content`
|
||||
4. On section load: calculate pagination based on current font_size + panel width → store page offsets
|
||||
5. On page change: slice `Section.content[pages[current_page]..pages[current_page+1]]` → render to egui `Label`
|
||||
6. On font_size change: recalculate all pagination for current section
|
||||
7. On theme toggle: switch `egui::Style` between light/dark visuals
|
||||
|
||||
## Pagination Algorithm
|
||||
|
||||
```
|
||||
fn paginate(text: &str, font_size: f32, panel_width: f32) -> Vec<usize>
|
||||
chars_per_line = floor(panel_width / (font_size * 0.6))
|
||||
lines_per_page = floor(panel_height / (font_size * 1.5))
|
||||
chars_per_page = chars_per_line * lines_per_page
|
||||
Iterate text, split at chars_per_page boundaries (prefer word/char boundaries)
|
||||
```
|
||||
|
||||
## File Storage
|
||||
|
||||
- `settings.json` (next to exe OR in standard config dir)
|
||||
- font_size, theme
|
||||
- recent_files: Vec<{path, title}>
|
||||
- reading_positions: HashMap<path, {section, page}>
|
||||
- bookmarks: HashMap<path, Vec<Bookmark>>
|
||||
- Last window size/position
|
||||
|
||||
## Error Handling
|
||||
|
||||
- ePub parsing failure → show error dialog with message
|
||||
- File not found (recent list) → remove from list silently
|
||||
- Font loading failure → fall back to system monospace
|
||||
- Settings file corruption → reset to defaults, log warning
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- Text search within book
|
||||
- Highlighting / annotations
|
||||
- Notes/export
|
||||
- TTS
|
||||
- Epub with complex CSS styling / reflowable layout math
|
||||
- DRM-protected ePubs
|
||||
BIN
fonts/NotoSansSC-Regular.ttf
Normal file
BIN
fonts/NotoSansSC-Regular.ttf
Normal file
Binary file not shown.
183
src/app.rs
183
src/app.rs
@@ -1,11 +1,186 @@
|
||||
pub struct App;
|
||||
use crate::book::Book;
|
||||
use crate::persistence;
|
||||
use crate::theme::{self, Settings};
|
||||
use eframe::egui;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct App {
|
||||
pub state: AppState,
|
||||
settings: Settings,
|
||||
settings_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub book: Option<Book>,
|
||||
pub current_section: usize,
|
||||
pub current_page: usize,
|
||||
pub sidebar_open: bool,
|
||||
pub file_path: Option<PathBuf>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(_cc: &eframe::CreationContext) -> Self {
|
||||
Self
|
||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let settings_dir = persistence::settings_dir();
|
||||
let settings = persistence::load_settings(&settings_dir).unwrap_or_default();
|
||||
cc.egui_ctx.set_style(theme::create_style(&settings.theme));
|
||||
Self {
|
||||
state: AppState {
|
||||
book: None,
|
||||
current_section: 0,
|
||||
current_page: 0,
|
||||
sidebar_open: false,
|
||||
file_path: None,
|
||||
error_message: None,
|
||||
},
|
||||
settings,
|
||||
settings_dir,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_file(&mut self, path: PathBuf) {
|
||||
match crate::book::load_epub(&path) {
|
||||
Ok(book) => {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let pos = self.settings.reading_positions.get(&path_str).copied();
|
||||
let mut recent = Vec::new();
|
||||
recent.push(path_str.clone());
|
||||
for f in &self.settings.recent_files {
|
||||
if *f != path_str {
|
||||
recent.push(f.clone());
|
||||
if recent.len() >= 10 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.settings.recent_files = recent;
|
||||
self.state.book = Some(book);
|
||||
self.state.current_section = pos.map(|p| p.section).unwrap_or(0);
|
||||
self.state.current_page = pos.map(|p| p.page).unwrap_or(0);
|
||||
self.state.sidebar_open = false;
|
||||
self.state.file_path = Some(path);
|
||||
self.state.error_message = None;
|
||||
self.save_settings();
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.error_message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_settings(&self) {
|
||||
let _ = persistence::save_settings(&self.settings_dir, &self.settings);
|
||||
}
|
||||
|
||||
fn save_reading_position(&mut self) {
|
||||
if let Some(ref path) = self.state.file_path {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
self.settings.reading_positions.insert(
|
||||
path_str,
|
||||
theme::ReadingPosition {
|
||||
section: self.state.current_section,
|
||||
page: self.state.current_page,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn welcome_view(&mut self, ctx: &egui::Context) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(150.0);
|
||||
ui.heading("ePub Reader");
|
||||
ui.add_space(20.0);
|
||||
if ui
|
||||
.add(egui::Button::new("📂 打开 ePub 文件").min_size(egui::vec2(200.0, 40.0)))
|
||||
.clicked()
|
||||
{
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.add_filter("ePub", &["epub"])
|
||||
.pick_file()
|
||||
{
|
||||
self.open_file(path);
|
||||
}
|
||||
}
|
||||
ui.add_space(30.0);
|
||||
if !self.settings.recent_files.is_empty() {
|
||||
ui.label("最近阅读:");
|
||||
ui.separator();
|
||||
let mut to_open: Option<PathBuf> = None;
|
||||
let mut to_remove: Option<usize> = None;
|
||||
for (i, path) in self.settings.recent_files.iter().enumerate() {
|
||||
let name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.clone());
|
||||
if ui.button(&name).clicked() {
|
||||
let p = std::path::PathBuf::from(path);
|
||||
if p.exists() {
|
||||
to_open = Some(p);
|
||||
} else {
|
||||
to_remove = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(path) = to_open {
|
||||
self.open_file(path);
|
||||
}
|
||||
if let Some(i) = to_remove {
|
||||
self.settings.recent_files.remove(i);
|
||||
self.save_settings();
|
||||
}
|
||||
}
|
||||
if let Some(ref msg) = self.state.error_message {
|
||||
ui.add_space(20.0);
|
||||
ui.colored_label(egui::Color32::RED, msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, _ctx: &eframe::egui::Context, _frame: &mut eframe::Frame) {}
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if self.state.book.is_none() {
|
||||
self.welcome_view(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let file_path = self.state.file_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let book = self.state.book.as_mut().unwrap();
|
||||
let action = crate::reader::reading_view(
|
||||
ui,
|
||||
book,
|
||||
&mut self.state.current_section,
|
||||
&mut self.state.current_page,
|
||||
&mut self.state.sidebar_open,
|
||||
&mut self.settings.font_size,
|
||||
&self.settings.theme,
|
||||
&file_path,
|
||||
);
|
||||
|
||||
if action.go_back {
|
||||
self.save_reading_position();
|
||||
self.state.book = None;
|
||||
self.state.current_section = 0;
|
||||
self.state.current_page = 0;
|
||||
}
|
||||
|
||||
if action.toggle_theme {
|
||||
self.settings.theme = match self.settings.theme {
|
||||
theme::Theme::Light => theme::Theme::Dark,
|
||||
theme::Theme::Dark => theme::Theme::Light,
|
||||
};
|
||||
ctx.set_style(theme::create_style(&self.settings.theme));
|
||||
}
|
||||
});
|
||||
|
||||
self.save_reading_position();
|
||||
self.save_settings();
|
||||
}
|
||||
}
|
||||
|
||||
114
src/book.rs
114
src/book.rs
@@ -43,6 +43,7 @@ pub struct TocEntry {
|
||||
pub struct Section {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
/// Populated by pagination algorithm (pre-computed char offsets for page boundaries)
|
||||
pub pages: Vec<usize>,
|
||||
}
|
||||
|
||||
@@ -55,10 +56,89 @@ pub struct Book {
|
||||
pub toc: Vec<TocEntry>,
|
||||
}
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub fn load_epub(path: impl AsRef<Path>) -> Result<Book, String> {
|
||||
let path = path.as_ref();
|
||||
let mut doc = epub::doc::EpubDoc::new(path)
|
||||
.map_err(|e| format!("无法打开文件: {}", e))?;
|
||||
|
||||
let title = doc.mdata("title").unwrap_or_else(|| "未知标题".to_string());
|
||||
let author = doc.mdata("creator").unwrap_or_else(|| "未知作者".to_string());
|
||||
let cover = doc.get_cover().ok();
|
||||
let spine = doc.spine.clone();
|
||||
|
||||
let mut sections = Vec::new();
|
||||
for (i, href) in spine.iter().enumerate() {
|
||||
let raw_html = doc.get_resource_str(href)
|
||||
.map_err(|e| format!("读取章节失败: {}", e))?;
|
||||
let text = strip_html(&raw_html);
|
||||
let title = extract_title(&raw_html)
|
||||
.unwrap_or_else(|| format!("第{}章", i + 1));
|
||||
sections.push(Section {
|
||||
title,
|
||||
content: text,
|
||||
pages: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw_toc = std::mem::take(&mut doc.toc);
|
||||
let toc = build_toc(&raw_toc, &spine);
|
||||
|
||||
Ok(Book { title, author, cover, sections, toc })
|
||||
}
|
||||
|
||||
fn extract_title(html: &str) -> Option<String> {
|
||||
if let Some(start) = html.find("<title>") {
|
||||
let rest = &html[start + 7..];
|
||||
if let Some(end) = rest.find("</title>") {
|
||||
return Some(strip_html(&rest[..end]).trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(start) = html.find("<h1") {
|
||||
let rest = &html[start..];
|
||||
if let Some(content_start) = rest.find('>') {
|
||||
let inner = &rest[content_start + 1..];
|
||||
if let Some(end) = inner.find("</h1>") {
|
||||
return Some(strip_html(&inner[..end]).trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn build_toc(
|
||||
entries: &[epub::doc::NavPoint],
|
||||
spine: &[String],
|
||||
) -> Vec<TocEntry> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let content_str = e.content.to_string_lossy();
|
||||
let section = spine
|
||||
.iter()
|
||||
.position(|s| content_str.contains(s.trim_end_matches('/')))
|
||||
// unwrap_or(0) is safe: a real TOC entry should always match a spine item
|
||||
.unwrap_or(0);
|
||||
TocEntry {
|
||||
label: e.label.clone(),
|
||||
section,
|
||||
children: build_toc(&e.children, spine),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_epub_loader_nonexistent_file() {
|
||||
let result = load_epub("nonexistent.epub");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_plain_text() {
|
||||
assert_eq!(strip_html("Hello World"), "Hello World");
|
||||
@@ -87,4 +167,38 @@ mod tests {
|
||||
fn test_strip_html_empty() {
|
||||
assert_eq!(strip_html(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_from_title_tag() {
|
||||
let html = "<html><head><title>My Book Title</title></head><body></body></html>";
|
||||
assert_eq!(extract_title(html), Some("My Book Title".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_from_h1() {
|
||||
let html = "<html><body><h1>Chapter One</h1><p>text</p></body></html>";
|
||||
assert_eq!(extract_title(html), Some("Chapter One".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_prefers_title() {
|
||||
let html = "<html><head><title>Book</title></head><body><h1>Chapter</h1></body></html>";
|
||||
assert_eq!(extract_title(html), Some("Book".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_missing() {
|
||||
assert_eq!(extract_title("<html><body><p>no title</p></body></html>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_empty() {
|
||||
assert_eq!(extract_title(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_toc_empty() {
|
||||
let toc = build_toc(&[], &[]);
|
||||
assert!(toc.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
19
src/font.rs
19
src/font.rs
@@ -1 +1,18 @@
|
||||
pub fn setup_fonts(_ctx: &eframe::egui::Context) {}
|
||||
pub fn setup_fonts(ctx: &eframe::egui::Context) {
|
||||
let mut fonts = eframe::egui::FontDefinitions::default();
|
||||
|
||||
let font_data = include_bytes!("../fonts/NotoSansSC-Regular.ttf");
|
||||
fonts.font_data.insert(
|
||||
"NotoSansSC".to_string(),
|
||||
eframe::egui::FontData::from_static(font_data).into(),
|
||||
);
|
||||
|
||||
if let Some(proportional) = fonts.families.get_mut(&eframe::egui::FontFamily::Proportional) {
|
||||
proportional.insert(0, "NotoSansSC".to_string());
|
||||
}
|
||||
if let Some(monospace) = fonts.families.get_mut(&eframe::egui::FontFamily::Monospace) {
|
||||
monospace.insert(0, "NotoSansSC".to_string());
|
||||
}
|
||||
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use crate::theme::Settings;
|
||||
use std::path::Path;
|
||||
|
||||
const SETTINGS_FILE: &str = "settings.json";
|
||||
|
||||
pub fn settings_dir() -> std::path::PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
}
|
||||
std::path::PathBuf::from(".")
|
||||
}
|
||||
|
||||
pub fn save_settings(dir: &Path, settings: &Settings) -> Result<(), String> {
|
||||
let path = dir.join(SETTINGS_FILE);
|
||||
let json = serde_json::to_string_pretty(settings)
|
||||
.map_err(|e| format!("序列化设置失败: {}", e))?;
|
||||
std::fs::write(&path, json)
|
||||
.map_err(|e| format!("保存设置失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_settings(dir: &Path) -> Result<Settings, String> {
|
||||
let path = dir.join(SETTINGS_FILE);
|
||||
let json = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("读取设置失败: {}", e))?;
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| format!("解析设置失败: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::{Settings, Theme};
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_settings() {
|
||||
let dir = std::env::temp_dir().join("epub-read-test");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
|
||||
let mut s = Settings::default();
|
||||
s.font_size = 24.0;
|
||||
s.theme = Theme::Dark;
|
||||
save_settings(&dir, &s).unwrap();
|
||||
|
||||
let loaded = load_settings(&dir).unwrap();
|
||||
assert_eq!(loaded.font_size, 24.0);
|
||||
assert_eq!(loaded.theme, Theme::Dark);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_settings_nonexistent() {
|
||||
let dir = std::env::temp_dir().join("epub-read-test-nonexistent");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let result = load_settings(&dir);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
284
src/reader.rs
284
src/reader.rs
@@ -0,0 +1,284 @@
|
||||
use eframe::egui;
|
||||
use crate::book::Book;
|
||||
use crate::theme::Theme;
|
||||
|
||||
pub fn recalculate_pages(book: &mut Book, font_size: f32, panel_width: f32, panel_height: f32) {
|
||||
let char_width = font_size * 0.6;
|
||||
let line_height = font_size * 1.5;
|
||||
let chars_per_line = if char_width > 0.0 {
|
||||
(panel_width / char_width).max(1.0) as usize
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let lines_per_page = if line_height > 0.0 {
|
||||
(panel_height / line_height).max(1.0) as usize
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let chars_per_page = chars_per_line * lines_per_page;
|
||||
for section in &mut book.sections {
|
||||
section.pages = calculate_pages(§ion.content, chars_per_page);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReaderAction {
|
||||
pub go_back: bool,
|
||||
pub toggle_theme: bool,
|
||||
pub toggle_bookmark: bool,
|
||||
}
|
||||
|
||||
pub fn reading_view(
|
||||
ui: &mut egui::Ui,
|
||||
book: &mut Book,
|
||||
current_section: &mut usize,
|
||||
current_page: &mut usize,
|
||||
sidebar_open: &mut bool,
|
||||
font_size: &mut f32,
|
||||
theme: &Theme,
|
||||
_file_path: &str,
|
||||
) -> ReaderAction {
|
||||
let mut action = ReaderAction {
|
||||
go_back: false,
|
||||
toggle_theme: false,
|
||||
toggle_bookmark: false,
|
||||
};
|
||||
|
||||
let panel_size = ui.available_size();
|
||||
recalculate_pages(book, *font_size, panel_size.x, panel_size.y);
|
||||
|
||||
// --- Sidebar (TOC) ---
|
||||
if *sidebar_open {
|
||||
egui::SidePanel::left("toc_sidebar")
|
||||
.resizable(true)
|
||||
.default_width(200.0)
|
||||
.show_inside(ui, |ui| {
|
||||
ui.heading("目录");
|
||||
ui.separator();
|
||||
render_toc(ui, &book.toc, &book.sections, current_section);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Top toolbar ---
|
||||
egui::TopBottomPanel::top("reader_toolbar")
|
||||
.show_inside(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("← 返回").clicked() {
|
||||
action.go_back = true;
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(&book.title);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let theme_icon = match theme {
|
||||
Theme::Dark => "☀️",
|
||||
Theme::Light => "🌙",
|
||||
};
|
||||
if ui.button(theme_icon).clicked() {
|
||||
action.toggle_theme = true;
|
||||
}
|
||||
if ui.button("🔖").clicked() {
|
||||
action.toggle_bookmark = true;
|
||||
}
|
||||
if ui.button("A⁻").clicked() {
|
||||
*font_size = (*font_size - 2.0).max(10.0);
|
||||
}
|
||||
if ui.button("A⁺").clicked() {
|
||||
*font_size = (*font_size + 2.0).min(48.0);
|
||||
}
|
||||
if ui.button("☰").clicked() {
|
||||
*sidebar_open = !*sidebar_open;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bottom progress bar ---
|
||||
let total_pages = if *current_section < book.sections.len() {
|
||||
let p = &book.sections[*current_section].pages;
|
||||
if p.len() > 1 { p.len() - 1 } else { 0 }
|
||||
} else { 0 };
|
||||
|
||||
egui::TopBottomPanel::bottom("reader_progress")
|
||||
.show_inside(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let section_title = book.sections.get(*current_section)
|
||||
.map(|s| s.title.as_str())
|
||||
.unwrap_or("");
|
||||
ui.label(section_title);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let label = if total_pages > 0 {
|
||||
format!("{}/{}", *current_page + 1, total_pages)
|
||||
} else {
|
||||
"1/1".to_string()
|
||||
};
|
||||
ui.label(label);
|
||||
let mut progress = if total_pages > 0 {
|
||||
*current_page as f32 / total_pages as f32
|
||||
} else { 0.0 };
|
||||
if ui.add(egui::Slider::new(&mut progress, 0.0..=1.0).text("")).changed() && total_pages > 0 {
|
||||
*current_page = (progress * total_pages as f32).round() as usize;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Center text area ---
|
||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||
let (rect, response) = ui.allocate_at_least(ui.available_size(), egui::Sense::click());
|
||||
|
||||
if let Some(section) = book.sections.get(*current_section) {
|
||||
if *current_page < section.pages.len().saturating_sub(1) {
|
||||
let start = section.pages[*current_page];
|
||||
let end = section.pages[*current_page + 1];
|
||||
let text: String = section.content.chars().skip(start).take(end - start).collect();
|
||||
ui.put(rect, |ui: &mut egui::Ui| {
|
||||
let color = match theme {
|
||||
Theme::Dark => egui::Color32::WHITE,
|
||||
Theme::Light => egui::Color32::BLACK,
|
||||
};
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&text).size(*font_size).color(color)
|
||||
).wrap()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Click navigation
|
||||
if response.clicked() {
|
||||
if let Some(click_pos) = response.interact_pointer_pos() {
|
||||
let x_ratio = (click_pos.x - rect.min.x) / rect.width();
|
||||
if x_ratio < 0.3 {
|
||||
if *current_page > 0 {
|
||||
*current_page -= 1;
|
||||
} else if *current_section > 0 {
|
||||
*current_section -= 1;
|
||||
*current_page = book.sections[*current_section]
|
||||
.pages.len().saturating_sub(2);
|
||||
}
|
||||
} else if x_ratio > 0.7 {
|
||||
if *current_page + 1 < book.sections[*current_section]
|
||||
.pages.len().saturating_sub(1)
|
||||
{
|
||||
*current_page += 1;
|
||||
} else if *current_section + 1 < book.sections.len() {
|
||||
*current_section += 1;
|
||||
*current_page = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
|
||||
if *current_page + 1 < book.sections[*current_section]
|
||||
.pages.len().saturating_sub(1)
|
||||
{
|
||||
*current_page += 1;
|
||||
} else if *current_section + 1 < book.sections.len() {
|
||||
*current_section += 1;
|
||||
*current_page = 0;
|
||||
}
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
|
||||
if *current_page > 0 {
|
||||
*current_page -= 1;
|
||||
} else if *current_section > 0 {
|
||||
*current_section -= 1;
|
||||
*current_page = book.sections[*current_section]
|
||||
.pages.len().saturating_sub(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
action
|
||||
}
|
||||
|
||||
fn render_toc(
|
||||
ui: &mut egui::Ui,
|
||||
entries: &[crate::book::TocEntry],
|
||||
_sections: &[crate::book::Section],
|
||||
current_section: &mut usize,
|
||||
) {
|
||||
for entry in entries {
|
||||
let is_current = entry.section == *current_section;
|
||||
let response = if is_current {
|
||||
ui.colored_label(egui::Color32::YELLOW, &entry.label)
|
||||
} else {
|
||||
ui.label(&entry.label)
|
||||
};
|
||||
if response.clicked() {
|
||||
*current_section = entry.section;
|
||||
}
|
||||
if !entry.children.is_empty() {
|
||||
ui.indent(&entry.label, |ui| {
|
||||
render_toc(ui, &entry.children, _sections, current_section);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_pages(text: &str, chars_per_page: usize) -> Vec<usize> {
|
||||
let mut pages = Vec::new();
|
||||
pages.push(0);
|
||||
|
||||
if text.is_empty() || chars_per_page == 0 {
|
||||
return pages;
|
||||
}
|
||||
|
||||
let total_chars = text.chars().count();
|
||||
if total_chars <= chars_per_page {
|
||||
pages.push(total_chars);
|
||||
return pages;
|
||||
}
|
||||
|
||||
let mut pos = 0;
|
||||
while pos < total_chars {
|
||||
pos = (pos + chars_per_page).min(total_chars);
|
||||
pages.push(pos);
|
||||
}
|
||||
|
||||
pages
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pagination_empty() {
|
||||
let pages = calculate_pages("", 100);
|
||||
assert_eq!(pages, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_shorter_than_page() {
|
||||
let pages = calculate_pages("Hello World", 100);
|
||||
assert_eq!(pages, vec![0, 11]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_exact_fit() {
|
||||
let pages = calculate_pages("ABCD", 4);
|
||||
assert_eq!(pages, vec![0, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_multiple_pages() {
|
||||
let text = "A".repeat(100);
|
||||
let pages = calculate_pages(&text, 30);
|
||||
assert_eq!(pages, vec![0, 30, 60, 90, 100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_single_char() {
|
||||
let pages = calculate_pages("A", 1);
|
||||
assert_eq!(pages, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_zero_chars_per_page() {
|
||||
let pages = calculate_pages("test", 0);
|
||||
assert_eq!(pages, vec![0]);
|
||||
}
|
||||
}
|
||||
|
||||
27
src/theme.rs
27
src/theme.rs
@@ -1,3 +1,5 @@
|
||||
use eframe::egui;
|
||||
use eframe::egui::Style;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
@@ -12,6 +14,19 @@ impl Default for Theme {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_style(theme: &Theme) -> Style {
|
||||
match theme {
|
||||
Theme::Light => Style {
|
||||
visuals: egui::Visuals::light(),
|
||||
..Default::default()
|
||||
},
|
||||
Theme::Dark => Style {
|
||||
visuals: egui::Visuals::dark(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub font_size: f32,
|
||||
@@ -69,6 +84,18 @@ mod tests {
|
||||
assert_eq!(restored.theme, s.theme);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_style_light_vs_dark() {
|
||||
let light = create_style(&Theme::Light);
|
||||
let dark = create_style(&Theme::Dark);
|
||||
// Light and dark should have different window fills
|
||||
assert_ne!(light.visuals.window_fill, dark.visuals.window_fill);
|
||||
// Dark mode should have darker window
|
||||
assert!(dark.visuals.window_fill.r() < light.visuals.window_fill.r());
|
||||
assert!(dark.visuals.window_fill.g() < light.visuals.window_fill.g());
|
||||
assert!(dark.visuals.window_fill.b() < light.visuals.window_fill.b());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_serialize() {
|
||||
let json = serde_json::to_string(&Theme::Dark).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user