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"
+ );
+ }
+
+ #[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();