From 17fbe7efbbe83d273738e47fa95a01597c88a5bf Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 13 May 2026 23:43:02 +0800 Subject: [PATCH] feat: add create_style function with light/dark theme support --- .../plans/2026-05-13-epub-reader.md | 1525 +++++++++++++++++ .../specs/2026-05-13-epub-reader-design.md | 162 ++ src/theme.rs | 27 + 3 files changed, 1714 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-epub-reader.md create mode 100644 docs/superpowers/specs/2026-05-13-epub-reader-design.md diff --git a/docs/superpowers/plans/2026-05-13-epub-reader.md b/docs/superpowers/plans/2026-05-13-epub-reader.md new file mode 100644 index 0000000..6205785 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-epub-reader.md @@ -0,0 +1,1525 @@ +# ePub Reader Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a standalone Windows ePub reader with egui, Kindle-like UI, Chinese font support. + +**Architecture:** Single-exe Rust application using `eframe` (egui) for UI, `epub` crate for ePub parsing, bundled Noto Sans SC font. App dispatches between Welcome screen and Reading view based on whether a book is open. The `App` struct holds all state; the book is parsed into a plain-text model with pre-computed page offsets. + +**Tech Stack:** Rust 1.94+ (x86_64-pc-windows-gnu), eframe 0.31, epub 1.2, rfd 0.15, serde/serde_json, Noto Sans SC font + +**Build Env:** MSYS2 MINGW64 terminal (`C:\msys64\mingw64.exe`) — all `cargo` commands must run there. + +--- + +### Task 1: Create project scaffold + +**Files:** +- Create: `Cargo.toml` +- Create: `.cargo/config.toml` +- Create: `src/main.rs` + +- [ ] **Step 1: Create directory structure** + +```bash +mkdir -p epub-read/src epub-read/.cargo +cd epub-read +``` + +- [ ] **Step 2: Write Cargo.toml** + +```toml +[package] +name = "epub-read" +version = "0.1.0" +edition = "2021" +description = "ePub reader with egui" + +[[bin]] +name = "epub-read" +path = "src/main.rs" + +[dependencies] +eframe = "0.31" +epub = "1.2" +rfd = "0.15" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = "symbols" +``` + +- [ ] **Step 3: Write .cargo/config.toml for static MinGW linking** + +```toml +[target.x86_64-pc-windows-gnu] +rustflags = ["-C", "target-feature=+crt-static"] +``` + +- [ ] **Step 4: Write src/main.rs** + +```rust +#![windows_subsystem = "windows"] + +mod app; +mod book; +mod font; +mod persistence; +mod reader; +mod theme; + +fn main() -> eframe::Result { + let native_options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([900.0, 700.0]) + .with_min_inner_size([600.0, 400.0]), + ..Default::default() + }; + eframe::run_native( + "ePub Reader", + native_options, + Box::new(|cc| { + font::setup_fonts(&cc.egui_ctx); + Ok(Box::new(app::App::new(cc))) + }), + ) +} +``` + +- [ ] **Step 5: Write placeholder module files** + +```bash +echo "" > src/app.rs +echo "" > src/book.rs +echo "" > src/font.rs +echo "" > src/persistence.rs +echo "" > src/reader.rs +echo "" > src/theme.rs +``` + +- [ ] **Step 6: Verify cargo compiles** + +Run in MINGW64: +```bash +cd /path/to/epub-read +cargo check +``` +Expected: Compilation succeeds (warnings about unused code OK). + +--- + +### Task 2: Define data models + +**Files:** +- Create: `src/book.rs` (Book, Section, TocEntry, strip_html + tests) +- Create: `src/theme.rs` (Theme, Settings + tests) + +- [ ] **Step 1: Write tests for strip_html** + +Append to `src/book.rs`: +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_html_plain_text() { + assert_eq!(strip_html("Hello World"), "Hello World"); + } + + #[test] + fn test_strip_html_simple_tags() { + assert_eq!(strip_html("

Hello

"), "Hello"); + } + + #[test] + fn test_strip_html_nested_tags() { + assert_eq!( + strip_html("

Hello World

"), + "Hello World" + ); + } + + #[test] + fn test_strip_html_html_entities() { + assert_eq!(strip_html("Hello & World"), "Hello & World"); + assert_eq!(strip_html("Hello World"), "Hello World"); + } + + #[test] + fn test_strip_html_empty() { + assert_eq!(strip_html(""), ""); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd /path/to/epub-read +cargo test --lib 2>&1 | head -20 +``` +Expected: Compile error — `strip_html` not defined. + +- [ ] **Step 3: Write strip_html + data models into book.rs** + +```rust +pub fn strip_html(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut in_tag = false; + let mut in_entity = false; + let mut entity = String::new(); + + for c in input.chars() { + match c { + '<' => in_tag = true, + '>' if in_tag => in_tag = false, + '&' if !in_tag => { + in_entity = true; + entity.clear(); + } + ';' if in_entity => { + in_entity = false; + let decoded = match entity.as_str() { + "amp" => "&", + "lt" => "<", + "gt" => ">", + "quot" => "\"", + "nbsp" => " ", + _ => "", + }; + result.push_str(decoded); + } + c if !in_tag && !in_entity => result.push(c), + c if in_entity => entity.push(c), + _ => {} + } + } + result +} + +#[derive(Debug, Clone)] +pub struct TocEntry { + pub label: String, + pub section: usize, + pub children: Vec, +} + +#[derive(Debug, Clone)] +pub struct Section { + pub title: String, + pub content: String, + pub pages: Vec, +} + +#[derive(Debug, Clone)] +pub struct Book { + pub title: String, + pub author: String, + pub cover: Option>, + pub sections: Vec
, + pub toc: Vec, +} +``` + +- [ ] **Step 4: Write Theme + Settings into theme.rs** + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum Theme { + Light, + Dark, +} + +impl Default for Theme { + fn default() -> Self { + Theme::Light + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub font_size: f32, + pub theme: Theme, + pub recent_files: Vec, + pub reading_positions: std::collections::HashMap, + pub bookmarks: std::collections::HashMap>, + pub window_size: Option<(f32, f32)>, +} + +impl Default for Settings { + fn default() -> Self { + Self { + font_size: 20.0, + theme: Theme::Light, + recent_files: Vec::new(), + reading_positions: std::collections::HashMap::new(), + bookmarks: std::collections::HashMap::new(), + window_size: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReadingPosition { + pub section: usize, + pub page: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Bookmark { + pub section: usize, + pub page: usize, + pub label: String, + pub timestamp: i64, +} +``` + +Write tests for Settings round-trip into theme.rs: +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_settings_default() { + let s = Settings::default(); + assert_eq!(s.font_size, 20.0); + assert_eq!(s.theme, Theme::Light); + } + + #[test] + fn test_settings_round_trip() { + let s = Settings::default(); + let json = serde_json::to_string(&s).unwrap(); + let restored: Settings = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.font_size, s.font_size); + assert_eq!(restored.theme, s.theme); + } + + #[test] + fn test_theme_serialize() { + let json = serde_json::to_string(&Theme::Dark).unwrap(); + assert_eq!(json, "\"Dark\""); + let restored: Theme = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, Theme::Dark); + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +cargo test +``` +Expected: All strip_html tests pass, all Settings/Theme round-trip tests pass. + +--- + +### Task 3: Implement EpubLoader + +**Files:** +- Modify: `src/book.rs` + +- [ ] **Step 1: Write test for EpubLoader (skips if no sample epub)** + +Append to `src/book.rs` tests: +```rust +#[test] +fn test_epub_loader_nonexistent_file() { + let result = load_epub("nonexistent.epub"); + assert!(result.is_err()); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cargo test --lib test_epub_loader -- --nocapture +``` +Expected: Compile error — `load_epub` not defined. + +- [ ] **Step 3: Implement EpubLoader in book.rs** + +Add the loader and a public `load_epub` function: + +```rust +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn load_epub(path: impl AsRef) -> Result { + 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 raw_toc = doc.toc.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 toc = build_toc(&raw_toc, &spine); + + Ok(Book { title, author, cover, sections, toc }) +} + +fn extract_title(html: &str) -> Option { + if let Some(start) = html.find("") { + let rest = &html[start + 7..]; + if let Some(end) = rest.find("") { + return Some(strip_html(&rest[..end]).trim().to_string()); + } + } + if let Some(start) = html.find("') { + let inner = &rest[content_start + 1..]; + if let Some(end) = inner.find("") { + return Some(strip_html(&inner[..end]).trim().to_string()); + } + } + } + None +} + +fn build_toc( + entries: &[epub::doc::TocEntry], + spine: &[String], +) -> Vec { + entries + .iter() + .map(|e| { + let section = spine + .iter() + .position(|s| e.content.contains(s.trim_end_matches('/'))) + .unwrap_or(0); + TocEntry { + label: e.label.clone(), + section, + children: build_toc(&e.children, spine), + } + }) + .collect() +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cargo test +``` +Expected: All existing tests pass (new test covers error case only). + +--- + +### Task 4: Implement pagination algorithm + +**Files:** +- Modify: `src/reader.rs` + +- [ ] **Step 1: Write pagination tests** + +```rust +#[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]); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cargo test --lib tests::test_pagination_empty -- --nocapture +``` +Expected: Compile error — `calculate_pages` not defined. + +- [ ] **Step 3: Implement calculate_pages in reader.rs** + +```rust +pub fn calculate_pages(text: &str, chars_per_page: usize) -> Vec { + 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 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cargo test +``` +Expected: All pagination tests pass. + +--- + +### Task 5: Implement font loading + +**Files:** +- Modify: `src/font.rs` + +- [ ] **Step 1: Download Noto Sans SC font** + +```bash +# Download Noto Sans SC Regular from GitHub +mkdir -p fonts +curl -L -o fonts/NotoSansSC-Regular.ttf \ + "https://github.com/googlefonts/noto-cjk/releases/download/Sans2.004/03_NotoSansCJKsc.zip" +``` + +Note: If the above URL fails, download manually from https://fonts.google.com/download?family=Noto+Sans+SC and place `NotoSansSC-Regular.ttf` in `fonts/`. + +- [ ] **Step 2: Implement font.rs** + +```rust +use eframe::egui; + +pub fn setup_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + + // Try to load bundled Chinese font + let font_data = include_bytes!("../fonts/NotoSansSC-Regular.ttf"); + fonts.font_data.insert( + "NotoSansSC".to_string(), + egui::FontData::from_static(font_data), + ); + + // Priority: NotoSansSC for CJK, then default proportional/ monospace + if let Some(proportional) = fonts.families.get_mut(&egui::FontFamily::Proportional) { + proportional.insert(0, "NotoSansSC".to_string()); + } + if let Some(monospace) = fonts.families.get_mut(&egui::FontFamily::Monospace) { + monospace.insert(0, "NotoSansSC".to_string()); + } + + ctx.set_fonts(fonts); +} +``` + +--- + +### Task 6: Implement settings persistence + +**Files:** +- Create: `src/persistence.rs` + +- [ ] **Step 1: Write round-trip tests** + +```rust +#[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()); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cargo test --lib persistence::tests -- --nocapture +``` +Expected: Compile error — functions not defined. + +- [ ] **Step 3: Implement persistence.rs** + +```rust +use crate::theme::Settings; +use std::path::Path; + +const SETTINGS_FILE: &str = "settings.json"; + +pub fn settings_dir() -> std::path::PathBuf { + // Use exe directory for portable settings + 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 { + 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)) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cargo test +``` +Expected: All persistence tests pass. + +--- + +### Task 7: Implement theme visuals + +**Files:** +- Modify: `src/theme.rs` + +- [ ] **Step 1: Write test for theme creation** + +```rust +#[test] +fn test_create_style_light() { + let style = create_style(&Theme::Light); + assert_eq!(style.visuals.window_fill, egui::Color32::from_rgb(255, 255, 255)); +} +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cargo test --lib theme::tests::test_create_style_light -- --nocapture +``` +Expected: Compile error — `create_style` not defined. + +- [ ] **Step 3: Implement create_style** + +Add imports at top of theme.rs: +```rust +use eframe::egui; +use eframe::egui::{Color32, Style, Visuals}; +``` + +Add function: +```rust +pub fn create_style(theme: &Theme) -> Style { + match theme { + Theme::Light => Style { + visuals: Visuals::light(), + ..Default::default() + }, + Theme::Dark => Style { + visuals: Visuals::dark(), + ..Default::default() + }, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cargo test +``` +Expected: All tests pass. + +--- + +### Task 8: Implement Welcome screen + +**Files:** +- Create: `src/app.rs` + +- [ ] **Step 1: Write welcome_view skeleton** + +app.rs: +```rust +use crate::book::Book; +use crate::persistence; +use crate::reader; +use crate::theme::{self, Settings, Theme}; +use eframe::egui; +use std::collections::HashMap; +use std::path::PathBuf; + +pub struct App { + pub state: AppState, + settings: Settings, + settings_dir: std::path::PathBuf, +} + +pub struct AppState { + pub book: Option, + pub current_section: usize, + pub current_page: usize, + pub sidebar_open: bool, + pub file_path: Option, + pub error_message: Option, +} + +impl App { + 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, + } + } +} +``` + +- [ ] **Step 2: Implement open_file method** + +```rust +impl App { + 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(); + + // Restore reading position + let pos = self.settings.reading_positions.get(&path_str).copied(); + + // Update recent files + 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, + }, + ); + } + } +} +``` + +- [ ] **Step 3: Implement welcome_view + main update** + +Append to app.rs: +```rust +impl App { + 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.button("📂 打开 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); + + // Recent files + if !self.settings.recent_files.is_empty() { + ui.label("最近阅读:"); + ui.separator(); + let mut to_remove: Option = 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() { + self.open_file(p); + } else { + to_remove = Some(i); + } + } + } + if let Some(i) = to_remove { + self.settings.recent_files.remove(i); + self.save_settings(); + } + } + + // Error dialog + if let Some(ref msg) = self.state.error_message { + ui.add_space(20.0); + ui.colored_label( + egui::Color32::RED, + msg, + ); + } + }); + }); + } +} +``` + +--- + +### Task 9: Implement Reading view + +**Files:** +- Modify: `src/reader.rs` + +- [ ] **Step 1: Write the full reading view** + +```rust +use crate::book::Book; +use crate::theme::{self, Theme}; +use eframe::egui; + +pub fn calculate_pages(text: &str, chars_per_page: usize) -> Vec { + 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 +} + +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) as usize + } else { + 1 + }; + let lines_per_page = if line_height > 0.0 { + (panel_height / line_height).max(1) 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 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, +) { + 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() { + // Return to welcome screen is handled by caller + } + ui.separator(); + ui.label(&book.title); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(if *theme == Theme::Dark { "☀️" } else { "🌙" }).clicked() { + // Theme toggle handled by caller + } + if ui.button("🔖").clicked() { + // Bookmark toggle handled by caller + } + 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 + }; + ui.add(egui::Slider::new(&mut progress, 0.0..=1.0).text("")); + if total_pages > 0 { + *current_page = (progress * total_pages as f32) as usize; + } + }); + }); + }); + + // --- Center text area --- + egui::CentralPanel::default().show_inside(ui, |ui| { + let (rect, response) = ui.allocate_space(ui.available_size(), egui::Sense::click()); + + // Render text + 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| { + ui.add( + egui::Label::new( + egui::RichText::new(&text) + .size(*font_size) + .color(if *theme == Theme::Dark { egui::Color32::WHITE } else { egui::Color32::BLACK }) + ) + .wrap(true) + ); + }); + } + } + + // Handle 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 { + // Previous page + 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 { + // Next page + 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); + } + } + }); +} + +fn render_toc( + ui: &mut egui::Ui, + entries: &[crate::book::TocEntry], + sections: &[crate::book::Section], + current_section: &mut usize, +) { + for entry in entries { + let label = &entry.label; + let is_current = entry.section == *current_section; + let response = if is_current { + ui.colored_label(egui::Color32::YELLOW, label) + } else { + ui.label(label) + }; + if response.clicked() { + *current_section = entry.section; + } + if !entry.children.is_empty() { + ui.indent(label, |ui| { + render_toc(ui, &entry.children, sections, current_section); + }); + } + } +} +``` + +--- + +### Task 10: Wire App and verify build + +**Files:** +- Modify: `src/app.rs` (full App with eframe::App impl) + +- [ ] **Step 1: Implement eframe::App for App** + +Add to `src/app.rs`: +```rust +impl eframe::App for App { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if self.state.book.is_none() { + self.welcome_view(ctx); + } else { + let mut go_back = false; + let mut toggle_theme = false; + let mut toggle_bookmark = false; + + // Extract book reference + let book = self.state.book.as_mut().unwrap(); + 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| { + 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, + ); + }); + + // Handle navigation from toolbar (simulated via state flags) + // For now, toolbar buttons in reading_view return through the book reference + // Theme toggle is handled here + if 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_settings(); + } + + // Auto-save reading position on each frame (throttled) + self.save_reading_position(); + self.save_settings(); + } + } +} +``` + +Wait, the toolbar buttons (theme toggle, back) are inside `reading_view` and can't directly set App's state. I need to redesign this. Let me use return values from `reading_view`. + +- [ ] **Step 2: Refactor reading_view to use return values** + +Change `reading_view` signature in `src/reader.rs`: +```rust +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, + }; + + // --- 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 + }; + let slider = egui::Slider::new(&mut progress, 0.0..=1.0).text(""); + if ui.add(slider).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_space(ui.available_size(), egui::Sense::click()); + + // Render text + 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(true) + ); + }); + } + } + + // Handle 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 +} +``` + +- [ ] **Step 3: Update App::update to use ReaderAction** + +```rust +impl eframe::App for App { + 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)); + } + }); + + // Save position and settings + self.save_reading_position(); + self.save_settings(); + } +} +``` + +- [ ] **Step 4: Build and fix compilation errors** + +```bash +cargo check 2>&1 +``` + +Fix any compilation errors iteratively: +```bash +cargo check 2>&1 | head -50 +``` + +Common issues to fix: +- Missing imports in various modules +- API mismatches between `epub` crate version and usage +- Method name differences in `egui` + +- [ ] **Step 5: Final successful build** + +```bash +cargo check +``` +Expected: Compilation succeeds with 0 errors (warnings OK). + +--- + +### Task 11: Build release binary + +**Files:** +- Modify: `.cargo/config.toml` (verify static linking config) + +- [ ] **Step 1: Verify static linking config** + +`.cargo/config.toml`: +```toml +[target.x86_64-pc-windows-gnu] +rustflags = ["-C", "target-feature=+crt-static"] +``` + +- [ ] **Step 2: Build release** +```bash +cargo build --release +``` +Expected: `target/release/epub-read.exe` produced. + +- [ ] **Step 3: Verify no DLL dependencies** + +```bash +# Check that the exe has no mingw DLL dependencies +objdump -p target/release/epub-read.exe | grep "DLL Name" +``` +Expected: No `libgcc_s_seh-1.dll`, `libwinpthread-1.dll`, or `libstdc++-6.dll` listed. Only Windows system DLLs. + +- [ ] **Step 4: Verify console is hidden** + +```bash +# Run the exe — should show no console window +./target/release/epub-read.exe & +``` +Expected: GUI window appears, no console/terminal window visible. + +- [ ] **Step 5: Verify with a real ePub file** + +Place a `.epub` file accessible, launch the app, click "打开 ePub 文件", select the file, verify: +- Chinese text renders correctly +- Pages advance with click/keyboard +- TOC sidebar works +- Font size adjustment works +- Theme toggle works +- Progress bar updates +- Re-opening the same file restores position + +--- + +## Self-Review Checklist + +1. **Spec coverage:** All 12 features from spec are covered: open file ✓, parse epub ✓, pagination ✓, navigation ✓, TOC ✓, reading position ✓, font size ✓, theme ✓, Chinese font ✓, bookmarks (UI stubs in toolbar, data model complete), settings persistence ✓, recent files ✓. + +2. **Placeholder scan:** No TBD/TODO. All code is complete, no vague descriptions. + +3. **Type consistency:** `Book`, `Section`, `TocEntry`, `Settings`, `Theme`, `Bookmark`, `ReadingPosition` — consistent across all tasks. `ReaderAction` return type connects Task 9 to Task 10. + +4. **Ambiguity check:** Bookmark functionality has data model but UI toggles are noted as stubs (🔖 button exists, action reported back to App). This is minimal viable scope — bookmark saving/listing can be added as follow-up. diff --git a/docs/superpowers/specs/2026-05-13-epub-reader-design.md b/docs/superpowers/specs/2026-05-13-epub-reader-design.md new file mode 100644 index 0000000..d4a55ac --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-epub-reader-design.md @@ -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, + current_section: usize, + current_page: usize, + settings: Settings, + bookmarks: Vec, + sidebar_open: bool, +} + +struct Book { + title: String, + author: String, + cover: Option>, + sections: Vec
, + toc: Vec, +} + +struct Section { + title: String, + content: String, // plain text (HTML tags stripped) + pages: Vec, // 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, +} +``` + +## 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 + 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 + - bookmarks: HashMap> + - 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 diff --git a/src/theme.rs b/src/theme.rs index 453d276..4f983dd 100644 --- a/src/theme.rs +++ b/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();