Files
epub-read/docs/superpowers/plans/2026-05-13-epub-reader.md

43 KiB

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

mkdir -p epub-read/src epub-read/.cargo
cd epub-read
  • Step 2: Write Cargo.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
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "target-feature=+crt-static"]
  • Step 4: Write src/main.rs
#![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
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:

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:

#[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("<p>Hello</p>"), "Hello");
    }

    #[test]
    fn test_strip_html_nested_tags() {
        assert_eq!(
            strip_html("<div><p>Hello <b>World</b></p></div>"),
            "Hello World"
        );
    }

    #[test]
    fn test_strip_html_html_entities() {
        assert_eq!(strip_html("Hello &amp; World"), "Hello & World");
        assert_eq!(strip_html("Hello&nbsp;World"), "Hello World");
    }

    #[test]
    fn test_strip_html_empty() {
        assert_eq!(strip_html(""), "");
    }
}
  • Step 2: Run tests to verify they fail
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
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<TocEntry>,
}

#[derive(Debug, Clone)]
pub struct Section {
    pub title: String,
    pub content: String,
    pub pages: Vec<usize>,
}

#[derive(Debug, Clone)]
pub struct Book {
    pub title: String,
    pub author: String,
    pub cover: Option<Vec<u8>>,
    pub sections: Vec<Section>,
    pub toc: Vec<TocEntry>,
}
  • Step 4: Write Theme + Settings into theme.rs
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<String>,
    pub reading_positions: std::collections::HashMap<String, ReadingPosition>,
    pub bookmarks: std::collections::HashMap<String, Vec<Bookmark>>,
    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:

#[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
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:

#[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
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:

use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

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 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<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::TocEntry],
    spine: &[String],
) -> Vec<TocEntry> {
    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
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

#[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
cargo test --lib tests::test_pagination_empty -- --nocapture

Expected: Compile error — calculate_pages not defined.

  • Step 3: Implement calculate_pages in reader.rs
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
}
  • Step 4: Run tests to verify they pass
cargo test

Expected: All pagination tests pass.


Task 5: Implement font loading

Files:

  • Modify: src/font.rs

  • Step 1: Download Noto Sans SC font

# 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
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

#[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
cargo test --lib persistence::tests -- --nocapture

Expected: Compile error — functions not defined.

  • Step 3: Implement persistence.rs
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<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))
}
  • Step 4: Run tests to verify they pass
cargo test

Expected: All persistence tests pass.


Task 7: Implement theme visuals

Files:

  • Modify: src/theme.rs

  • Step 1: Write test for theme creation

#[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
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:

use eframe::egui;
use eframe::egui::{Color32, Style, Visuals};

Add function:

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
cargo test

Expected: All tests pass.


Task 8: Implement Welcome screen

Files:

  • Create: src/app.rs

  • Step 1: Write welcome_view skeleton

app.rs:

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<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 {
        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
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:

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<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() {
                                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

use crate::book::Book;
use crate::theme::{self, Theme};
use eframe::egui;

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
}

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(&section.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:

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:

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
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
cargo check 2>&1

Fix any compilation errors iteratively:

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

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:

[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "target-feature=+crt-static"]
  • Step 2: Build release
cargo build --release

Expected: target/release/epub-read.exe produced.

  • Step 3: Verify no DLL dependencies
# 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
# 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.