Compare commits
50 Commits
88afb217c6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4af235eab | ||
|
|
e4dfc80979 | ||
|
|
b8bdd97d80 | ||
|
|
f95d1c418f | ||
|
|
00ea81137b | ||
|
|
0b076a8c26 | ||
|
|
11385a68dc | ||
|
|
dc6e10d10e | ||
|
|
2e6ac83759 | ||
|
|
528d70fc33 | ||
| 1d2407098c | |||
| 21e9aba274 | |||
|
|
f12297d580 | ||
| 8e8ba01336 | |||
|
|
af141b581d | ||
|
|
6bd96cd913 | ||
|
|
afa8ea6ce7 | ||
|
|
3c54cc1e55 | ||
|
|
4e7768d265 | ||
|
|
f76c59b6cf | ||
| f2b5be312c | |||
| af03d18470 | |||
|
|
4add295bfa | ||
|
|
006cb3cd5e | ||
|
|
31efa90a87 | ||
|
|
7602121fda | ||
|
|
cbb0ee373a | ||
|
|
a6e011261d | ||
|
|
93f529e700 | ||
|
|
362acfddbf | ||
|
|
9d09e991f4 | ||
|
|
669650147b | ||
|
|
e2ed63a982 | ||
|
|
33ec709a5e | ||
|
|
0d0700cf89 | ||
|
|
1cb0c2aef2 | ||
|
|
4e79181e07 | ||
| eecab02ead | |||
| 19336d5d34 | |||
| b51ce6853b | |||
| 2df605c864 | |||
|
|
88f15d307a | ||
|
|
a82fa6b7b6 | ||
|
|
0521439281 | ||
|
|
4655ad5d31 | ||
|
|
515ec0e07d | ||
|
|
7d056e2670 | ||
|
|
d69229b1ca | ||
|
|
16f801cdf8 | ||
| b0071c6617 |
@@ -1,2 +1,6 @@
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
|
||||
[env]
|
||||
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER = "x86_64-w64-mingw32-gcc"
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1 +1,9 @@
|
||||
/target/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
*.lnk
|
||||
662
Cargo.lock
generated
662
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,10 @@ name = "epub-read"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "ePub reader with egui"
|
||||
build = "build.rs"
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "2.4"
|
||||
|
||||
[[bin]]
|
||||
name = "epub-read"
|
||||
@@ -14,6 +18,7 @@ epub = "1.2"
|
||||
rfd = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
image = "0.25"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
||||
57
README.md
57
README.md
@@ -4,21 +4,50 @@
|
||||
|
||||
## 截图
|
||||
|
||||

|
||||
|
||||
启动页 → 选择 ePub 文件 → Kindle 风格阅读界面。
|
||||
|
||||
## 功能
|
||||
|
||||
- 打开 .epub 文件(原生文件对话框)
|
||||
- Kindle 风格阅读界面(顶部工具栏 + 中央正文 + 底部进度条)
|
||||
- 点击左右区域翻页(左 30% 上一页、右 30% 下一页)
|
||||
- 键盘 ← → 翻页
|
||||
- 目录侧栏(可展开/收起)
|
||||
- 字体大小调节(A⁺ / A⁻)
|
||||
- 日间/夜间模式切换
|
||||
- 底部进度条可拖动跳转
|
||||
- 自动保存阅读位置
|
||||
- 最近文件列表
|
||||
- 设置持久化(settings.json)
|
||||
- 📂 打开 .epub 文件(原生文件对话框)
|
||||
- 📖 Kindle 风格阅读界面(顶部工具栏 + 中央正文 + 底部进度条)
|
||||
- 🖱️ 点击左右区域翻页(左 30% 上一页、右 30% 下一页)
|
||||
- ⌨️ 键盘 ← → 翻页
|
||||
- 📋 目录侧栏(可展开/收起)
|
||||
- 🔖 书签功能
|
||||
- 🔍 字体大小调节(A⁺ / A⁻)
|
||||
- 🌓 日间/夜间/棕褐色模式切换
|
||||
- 🎨 多种背景纹理(牛皮纸/稿纸/作文纸/自定义图片)
|
||||
- 📏 响应式分页引擎(基于 egui Galley 精确测量)
|
||||
- 🔤 字体切换(内置三款中文字体:思源黑体 / 霞鹜文楷 / 思源宋体)
|
||||
- 🔗 内容锚点定位(字体/窗口大小变化后自动恢复阅读位置)
|
||||
- 📊 底部进度条可拖动跳转
|
||||
- 💾 自动保存阅读位置
|
||||
- 📅 最近文件列表
|
||||
- ⚙️ 设置持久化(settings.json)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
epub-read/
|
||||
├── src/
|
||||
│ ├── main.rs # 程序入口
|
||||
│ ├── app.rs # App 状态管理 + 事件循环
|
||||
│ ├── book.rs # ePub 解析 + 章节数据结构
|
||||
│ ├── reader.rs # 响应式分页引擎 + 阅读视图渲染
|
||||
│ ├── theme.rs # 主题/配色/设置/书签/阅读位置
|
||||
│ ├── style.rs # 排版样式配置(对齐/行高/缩进)
|
||||
│ ├── texture.rs # 背景纹理生成
|
||||
│ ├── font.rs # 字体注册/加载/切换
|
||||
│ └── persistence.rs # 配置文件读写
|
||||
├── fonts/
|
||||
│ ├── NotoSansSC-Regular.ttf # 思源黑体(内嵌)
|
||||
│ ├── LXGWWenKai-Regular.ttf # 霞鹜文楷(运行时加载)
|
||||
│ └── SourceHanSerifSC-Regular.otf # 思源宋体(运行时加载)
|
||||
├── Cargo.toml
|
||||
└── .cargo/config.toml # 静态链接配置
|
||||
```
|
||||
|
||||
## 系统要求
|
||||
|
||||
@@ -45,7 +74,7 @@ cargo build --release
|
||||
### 静态链接说明
|
||||
|
||||
已配置 `.cargo/config.toml` 启用 `+crt-static`,编译出的 exe 不依赖 MinGW 运行时 DLL。
|
||||
内嵌 Noto Sans SC 中文字体(约 17MB),确保中文正常显示。
|
||||
内嵌 Noto Sans SC 中文字体(思源黑体),额外两款字体(霞鹜文楷、思源宋体)放在 exe 同级 `fonts/` 目录运行时加载。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -55,12 +84,12 @@ cargo build --release
|
||||
| ePub 解析 | [epub](https://crates.io/crates/epub) |
|
||||
| 文件对话框 | [rfd](https://crates.io/crates/rfd) |
|
||||
| 序列化 | [serde](https://crates.io/crates/serde) / [serde_json](https://crates.io/crates/serde_json) |
|
||||
| 中文字体 | [Noto Sans SC](https://fonts.google.com/specimen/Noto+Sans+SC) |
|
||||
| 中文字体 | 思源黑体 / [霞鹜文楷](https://github.com/lxgw/LxgwWenkai-Lite) / [思源宋体](https://github.com/adobe-fonts/source-han-serif) |
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
cargo test # 运行测试(24 个)
|
||||
cargo test # 运行测试(33 个)
|
||||
cargo check # 编译检查
|
||||
cargo run # 调试运行
|
||||
```
|
||||
|
||||
17
build.rs
Normal file
17
build.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
fn main() {
|
||||
let windres_available = std::process::Command::new("where")
|
||||
.arg("windres")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if windres_available {
|
||||
embed_resource::compile("read.rc", embed_resource::NONE);
|
||||
} else {
|
||||
println!("cargo:warning=windres not found, skipping Windows resource (icon) compilation");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn main() {}
|
||||
BIN
epub-read.exe
Normal file
BIN
epub-read.exe
Normal file
Binary file not shown.
BIN
fonts/LXGWWenKai-Regular.ttf
Normal file
BIN
fonts/LXGWWenKai-Regular.ttf
Normal file
Binary file not shown.
BIN
fonts/SourceHanSerifSC-Regular.otf
Normal file
BIN
fonts/SourceHanSerifSC-Regular.otf
Normal file
Binary file not shown.
BIN
sample-short.epub
Normal file
BIN
sample-short.epub
Normal file
Binary file not shown.
368
src/app.rs
368
src/app.rs
@@ -1,6 +1,7 @@
|
||||
use crate::book::Book;
|
||||
use crate::persistence;
|
||||
use crate::theme::{self, Settings};
|
||||
use crate::style::StyleProfile;
|
||||
use crate::theme::{self, BgType, Settings};
|
||||
use eframe::egui;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -8,6 +9,10 @@ pub struct App {
|
||||
pub state: AppState,
|
||||
settings: Settings,
|
||||
settings_dir: std::path::PathBuf,
|
||||
kraft_texture: Option<egui::TextureHandle>,
|
||||
manuscript_texture: Option<egui::TextureHandle>,
|
||||
composition_texture: Option<egui::TextureHandle>,
|
||||
custom_texture: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
@@ -17,6 +22,34 @@ pub struct AppState {
|
||||
pub sidebar_open: bool,
|
||||
pub file_path: Option<PathBuf>,
|
||||
pub error_message: Option<String>,
|
||||
pub pending_anchor: Option<theme::ReadingPosition>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn prev_page(&mut self) {
|
||||
if let Some(ref book) = self.book {
|
||||
if self.current_page > 0 {
|
||||
self.current_page -= 1;
|
||||
} else if self.current_section > 0 {
|
||||
self.current_section -= 1;
|
||||
self.current_page = book.sections[self.current_section]
|
||||
.page_block_ranges.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_page(&mut self) {
|
||||
if let Some(ref book) = self.book {
|
||||
if self.current_page + 1 < book.sections[self.current_section]
|
||||
.page_block_ranges.len()
|
||||
{
|
||||
self.current_page += 1;
|
||||
} else if self.current_section + 1 < book.sections.len() {
|
||||
self.current_section += 1;
|
||||
self.current_page = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -24,6 +57,7 @@ impl App {
|
||||
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));
|
||||
crate::font::setup_fonts(&cc.egui_ctx, &settings.font_name);
|
||||
Self {
|
||||
state: AppState {
|
||||
book: None,
|
||||
@@ -32,9 +66,14 @@ impl App {
|
||||
sidebar_open: false,
|
||||
file_path: None,
|
||||
error_message: None,
|
||||
pending_anchor: None,
|
||||
},
|
||||
settings,
|
||||
settings_dir,
|
||||
kraft_texture: Some(crate::texture::generate_kraft_paper(&cc.egui_ctx)),
|
||||
manuscript_texture: Some(crate::texture::generate_manuscript(&cc.egui_ctx)),
|
||||
composition_texture: Some(crate::texture::generate_composition(&cc.egui_ctx)),
|
||||
custom_texture: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +81,7 @@ impl App {
|
||||
match crate::book::load_epub(&path) {
|
||||
Ok(book) => {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let pos = self.settings.reading_positions.get(&path_str).copied();
|
||||
let pos = self.settings.reading_positions.get(&path_str).cloned();
|
||||
let mut recent = Vec::new();
|
||||
recent.push(path_str.clone());
|
||||
for f in &self.settings.recent_files {
|
||||
@@ -55,8 +94,9 @@ impl App {
|
||||
}
|
||||
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.current_section = pos.as_ref().map(|p| p.section).unwrap_or(0);
|
||||
self.state.current_page = pos.as_ref().map(|p| p.page).unwrap_or(0);
|
||||
self.state.pending_anchor = pos;
|
||||
self.state.sidebar_open = false;
|
||||
self.state.file_path = Some(path);
|
||||
self.state.error_message = None;
|
||||
@@ -72,14 +112,36 @@ impl App {
|
||||
let _ = persistence::save_settings(&self.settings_dir, &self.settings);
|
||||
}
|
||||
|
||||
fn active_profile(&self) -> &StyleProfile {
|
||||
let idx = self.settings.profiles.iter().position(|p| p.name == self.settings.active_profile);
|
||||
let idx = idx.unwrap_or(0);
|
||||
&self.settings.profiles[idx]
|
||||
}
|
||||
|
||||
fn save_reading_position(&mut self) {
|
||||
if let Some(ref path) = self.state.file_path {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let (block_index, text_snippet) = if let Some(ref book) = self.state.book {
|
||||
let section = &book.sections[self.state.current_section];
|
||||
if let Some(&(start, _end)) = section.page_block_ranges.get(self.state.current_page) {
|
||||
let first_block = start;
|
||||
let snippet = section.blocks.get(first_block)
|
||||
.map(|b| b.text.chars().take(30).collect())
|
||||
.unwrap_or_default();
|
||||
(Some(first_block), Some(snippet))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
self.settings.reading_positions.insert(
|
||||
path_str,
|
||||
theme::ReadingPosition {
|
||||
section: self.state.current_section,
|
||||
page: self.state.current_page,
|
||||
block_index,
|
||||
text_snippet,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -140,47 +202,277 @@ impl App {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let bg_type = self.settings.bg_type.clone();
|
||||
let has_bg = bg_type.has_background();
|
||||
|
||||
let file_path = self.state.file_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
// 有背景时面板设为透明
|
||||
if has_bg {
|
||||
ctx.style_mut(|s| {
|
||||
s.visuals.panel_fill = egui::Color32::TRANSPARENT;
|
||||
s.visuals.window_fill = egui::Color32::TRANSPARENT;
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
// 全窗口背景纹理
|
||||
let viewport_rect = ctx.input(|i| i.screen_rect());
|
||||
match bg_type {
|
||||
BgType::Kraft => {
|
||||
if let Some(tex) = &self.kraft_texture {
|
||||
crate::texture::draw_tiled_bg(
|
||||
&ctx.layer_painter(egui::LayerId::background()),
|
||||
viewport_rect,
|
||||
tex,
|
||||
);
|
||||
}
|
||||
}
|
||||
BgType::Manuscript => {
|
||||
if let Some(tex) = &self.manuscript_texture {
|
||||
crate::texture::draw_tiled_bg(
|
||||
&ctx.layer_painter(egui::LayerId::background()),
|
||||
viewport_rect,
|
||||
tex,
|
||||
);
|
||||
}
|
||||
}
|
||||
BgType::Composition => {
|
||||
if let Some(tex) = &self.composition_texture {
|
||||
crate::texture::draw_tiled_bg(
|
||||
&ctx.layer_painter(egui::LayerId::background()),
|
||||
viewport_rect,
|
||||
tex,
|
||||
);
|
||||
}
|
||||
}
|
||||
BgType::Custom(ref path) if !path.is_empty() => {
|
||||
if self.custom_texture.is_none() {
|
||||
match image::ImageReader::open(path) {
|
||||
Ok(reader) => match reader.decode() {
|
||||
Ok(img) => {
|
||||
let size = [img.width() as usize, img.height() as usize];
|
||||
let pixels: Vec<egui::Color32> = img
|
||||
.to_rgba8()
|
||||
.pixels()
|
||||
.map(|p| {
|
||||
egui::Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3])
|
||||
})
|
||||
.collect();
|
||||
let image = egui::ColorImage { size, pixels };
|
||||
self.custom_texture = Some(
|
||||
ctx.load_texture("custom_bg", image, Default::default()),
|
||||
);
|
||||
}
|
||||
Err(_) => {}
|
||||
},
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
if let Some(tex) = &self.custom_texture {
|
||||
let painter = &ctx.layer_painter(egui::LayerId::background());
|
||||
painter.image(
|
||||
tex.id(),
|
||||
viewport_rect,
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if action.go_back {
|
||||
self.save_reading_position();
|
||||
self.state.book = None;
|
||||
self.state.current_section = 0;
|
||||
self.state.current_page = 0;
|
||||
}
|
||||
if self.state.book.is_none() {
|
||||
self.welcome_view(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if action.toggle_theme {
|
||||
let file_path = self.state.file_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut style = self.active_profile().clone();
|
||||
let theme_copy = self.settings.theme;
|
||||
let profile_names: Vec<String> = self.settings.profiles.iter().map(|p| p.name.clone()).collect();
|
||||
|
||||
let bookmarks = self.settings.bookmarks.get(&file_path).cloned().unwrap_or_default();
|
||||
let mut jump_to_bookmark: Option<usize> = None;
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let book = self.state.book.as_mut().unwrap();
|
||||
let current_font = self.settings.font_name.clone();
|
||||
let (action, jump) = crate::reader::reading_view(
|
||||
ui,
|
||||
book,
|
||||
&mut self.state.current_section,
|
||||
&mut self.state.current_page,
|
||||
&mut self.state.sidebar_open,
|
||||
&mut style,
|
||||
&theme_copy,
|
||||
bg_type.clone(),
|
||||
&file_path,
|
||||
&profile_names,
|
||||
&bookmarks,
|
||||
¤t_font,
|
||||
);
|
||||
jump_to_bookmark = jump;
|
||||
|
||||
if action.go_back {
|
||||
self.save_reading_position();
|
||||
self.state.book = None;
|
||||
self.state.current_section = 0;
|
||||
self.state.current_page = 0;
|
||||
}
|
||||
|
||||
// Switch profile immediately
|
||||
if let Some(ref target) = action.switch_to_profile {
|
||||
if let Some(profile) = self.settings.profiles.iter()
|
||||
.find(|p| p.name == *target)
|
||||
{
|
||||
style.alignment = profile.alignment;
|
||||
style.line_spacing = profile.line_spacing;
|
||||
style.paragraph_spacing = profile.paragraph_spacing;
|
||||
style.first_line_indent = profile.first_line_indent;
|
||||
style.font_size = profile.font_size;
|
||||
style.name = profile.name.clone();
|
||||
self.settings.active_profile = profile.name.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref new_font) = action.switch_font {
|
||||
if new_font != &self.settings.font_name {
|
||||
let actual = crate::font::setup_fonts(ui.ctx(), new_font);
|
||||
self.settings.font_name = actual;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(new_bg) = action.switch_bg {
|
||||
self.settings.bg_type = new_bg;
|
||||
// 切换背景时,如果选自定义图片则弹出文件选择器
|
||||
if let BgType::Custom(_) = &self.settings.bg_type {
|
||||
if let Some(p) = rfd::FileDialog::new()
|
||||
.add_filter("Images", &["png", "jpg", "jpeg", "bmp", "webp"])
|
||||
.pick_file()
|
||||
{
|
||||
self.settings.bg_type = BgType::Custom(p.to_string_lossy().to_string());
|
||||
self.custom_texture = None;
|
||||
} else {
|
||||
self.settings.bg_type = BgType::None;
|
||||
}
|
||||
}
|
||||
// 切换背景时,如果从自定义图片切走,清除纹理缓存
|
||||
if !self.settings.bg_type.has_background() || !matches!(self.settings.bg_type, BgType::Custom(_)) {
|
||||
self.custom_texture = None;
|
||||
}
|
||||
}
|
||||
|
||||
if action.toggle_theme {
|
||||
self.settings.theme = match self.settings.theme {
|
||||
theme::Theme::Light => theme::Theme::Dark,
|
||||
theme::Theme::Dark => theme::Theme::Light,
|
||||
theme::Theme::Dark => theme::Theme::Sepia,
|
||||
theme::Theme::Sepia => theme::Theme::Light,
|
||||
};
|
||||
ctx.set_style(theme::create_style(&self.settings.theme));
|
||||
}
|
||||
});
|
||||
|
||||
self.save_reading_position();
|
||||
self.save_settings();
|
||||
}
|
||||
}
|
||||
if action.toggle_sidebar {
|
||||
self.state.sidebar_open = !self.state.sidebar_open;
|
||||
}
|
||||
|
||||
if let Some(section) = action.jump_to_section {
|
||||
self.state.current_section = section;
|
||||
self.state.current_page = 0;
|
||||
self.state.pending_anchor = None;
|
||||
if let Some(ref anchor) = action.jump_to_anchor {
|
||||
if let Some(ref book) = self.state.book {
|
||||
if section < book.sections.len() {
|
||||
if let Some(page) = crate::reader::find_page_for_anchor(&book.sections[section], anchor) {
|
||||
self.state.current_page = page;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if action.page_prev {
|
||||
self.state.prev_page();
|
||||
}
|
||||
if action.page_next {
|
||||
self.state.next_page();
|
||||
}
|
||||
|
||||
if action.toggle_bookmark {
|
||||
let file_path = file_path.clone();
|
||||
let section = self.state.current_section;
|
||||
let page = self.state.current_page;
|
||||
let book = self.state.book.as_ref().unwrap();
|
||||
let section_title = book.sections.get(section)
|
||||
.map(|s| s.title.clone())
|
||||
.unwrap_or_else(|| format!("第{}章", section + 1));
|
||||
|
||||
let bookmarks = self.settings.bookmarks.entry(file_path)
|
||||
.or_insert_with(Vec::new);
|
||||
|
||||
let existing_idx = bookmarks.iter()
|
||||
.position(|b: &theme::Bookmark| b.section == section && b.page == page);
|
||||
|
||||
if let Some(idx) = existing_idx {
|
||||
bookmarks.remove(idx);
|
||||
} else {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
bookmarks.push(theme::Bookmark {
|
||||
section,
|
||||
page,
|
||||
label: format!("{} - 第{}页", section_title, page + 1),
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(idx) = jump_to_bookmark {
|
||||
if let Some(bookmarks) = self.settings.bookmarks.get(&file_path) {
|
||||
if let Some(bm) = bookmarks.get(idx) {
|
||||
self.state.current_section = bm.section;
|
||||
self.state.current_page = bm.page;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(anchor) = self.state.pending_anchor.take() {
|
||||
if let Some(ref book) = self.state.book {
|
||||
if anchor.section < book.sections.len() {
|
||||
let section = &book.sections[anchor.section];
|
||||
let restored = if let Some(block_idx) = anchor.block_index {
|
||||
crate::reader::find_page_for_block(section, block_idx)
|
||||
} else if let Some(ref snippet) = anchor.text_snippet {
|
||||
crate::reader::find_page_by_snippet(section, snippet)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(page) = restored {
|
||||
self.state.current_page = page;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync style changes back to active profile (outside closure)
|
||||
if let Some(p) = self.settings.profiles.iter_mut()
|
||||
.find(|p| p.name == self.settings.active_profile)
|
||||
{
|
||||
p.font_size = style.font_size;
|
||||
p.alignment = style.alignment;
|
||||
p.line_spacing = style.line_spacing;
|
||||
p.paragraph_spacing = style.paragraph_spacing;
|
||||
p.first_line_indent = style.first_line_indent;
|
||||
}
|
||||
self.settings.font_size = style.font_size;
|
||||
|
||||
self.save_reading_position();
|
||||
self.save_settings();
|
||||
}
|
||||
}
|
||||
|
||||
479
src/book.rs
479
src/book.rs
@@ -1,50 +1,260 @@
|
||||
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();
|
||||
fn decode_entities(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut chars = text.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '&' {
|
||||
let mut entity = String::new();
|
||||
for ec in &mut chars {
|
||||
if ec == ';' {
|
||||
break;
|
||||
}
|
||||
entity.push(ec);
|
||||
}
|
||||
';' 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),
|
||||
_ => {}
|
||||
let decoded = match entity.as_str() {
|
||||
"amp" => "&",
|
||||
"lt" => "<",
|
||||
"gt" => ">",
|
||||
"quot" => "\"",
|
||||
"nbsp" => " ",
|
||||
"emsp" => " ",
|
||||
"ensp" => " ",
|
||||
"mdash" => "—",
|
||||
"ndash" => "–",
|
||||
"ldquo" => "\"",
|
||||
"rdquo" => "\"",
|
||||
"lsquo" => "'",
|
||||
"rsquo" => "'",
|
||||
"hellip" => "…",
|
||||
_ => "",
|
||||
};
|
||||
result.push_str(decoded);
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn tag_name_from(tag_content: &str) -> &str {
|
||||
tag_content
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('/')
|
||||
}
|
||||
|
||||
fn extract_id_from_tag(tag_content: &str) -> Option<String> {
|
||||
if let Some(id_pos) = tag_content.find("id=\"") {
|
||||
let after_quote = &tag_content[id_pos + 4..];
|
||||
if let Some(end_quote) = after_quote.find('\"') {
|
||||
let id_val = &after_quote[..end_quote];
|
||||
if !id_val.is_empty() {
|
||||
return Some(id_val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id_pos) = tag_content.find("id='") {
|
||||
let after_quote = &tag_content[id_pos + 4..];
|
||||
if let Some(end_quote) = after_quote.find('\'') {
|
||||
let id_val = &after_quote[..end_quote];
|
||||
if !id_val.is_empty() {
|
||||
return Some(id_val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn strip_html(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut pos = 0;
|
||||
let mut heading_level: Option<u32> = None;
|
||||
let mut pending_anchor: Option<String> = None;
|
||||
|
||||
while pos < input.len() {
|
||||
// Find next '<'
|
||||
let remaining = &input[pos..];
|
||||
let tag_start = remaining.find('<');
|
||||
|
||||
let tag_start = match tag_start {
|
||||
Some(s) => pos + s,
|
||||
None => {
|
||||
// No more tags, emit remaining text
|
||||
out.push_str(&decode_entities(&input[pos..]));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Emit text before the tag
|
||||
if tag_start > pos {
|
||||
let text = decode_entities(&input[pos..tag_start]);
|
||||
if let Some(level) = heading_level {
|
||||
out.push('\x01');
|
||||
out.push(char::from_digit(level, 10).unwrap_or('1'));
|
||||
if let Some(ref anchor) = pending_anchor {
|
||||
out.push('\x03');
|
||||
out.push_str(anchor);
|
||||
out.push('\x04');
|
||||
}
|
||||
out.push_str(&text);
|
||||
out.push('\x02');
|
||||
pending_anchor = None;
|
||||
} else {
|
||||
out.push_str(&text);
|
||||
}
|
||||
}
|
||||
|
||||
// Find '>' to close the tag
|
||||
let tag_end = match input[tag_start..].find('>') {
|
||||
Some(i) => tag_start + i,
|
||||
None => {
|
||||
// Unclosed tag, emit rest as text
|
||||
out.push_str(&decode_entities(&input[tag_start..]));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let tag_content = &input[tag_start + 1..tag_end];
|
||||
let name = tag_name_from(tag_content);
|
||||
|
||||
match name {
|
||||
"script" | "style" => {
|
||||
// Skip content until closing tag
|
||||
let close_tag = format!("</{}", name);
|
||||
if let Some(cs) = input[tag_end..].find(&close_tag) {
|
||||
let close_tag_end = input[tag_end + cs..].find('>');
|
||||
if let Some(ce) = close_tag_end {
|
||||
pos = tag_end + cs + ce + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
"a" => {
|
||||
// Capture anchor id
|
||||
if heading_level.is_none() {
|
||||
pending_anchor = extract_id_from_tag(tag_content);
|
||||
}
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
"br" => {
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
"hr" => {
|
||||
if !out.is_empty() {
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
out.push_str("---\n\n");
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
"li" => {
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("- ");
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
"/li" | "/dd" | "/dt" | "/ol" | "/ul" => {
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
|
||||
let level = name[1..2].parse::<u32>().unwrap_or(1);
|
||||
heading_level = Some(level);
|
||||
if pending_anchor.is_none() {
|
||||
pending_anchor = extract_id_from_tag(tag_content);
|
||||
}
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
"p" | "div" | "blockquote" => {
|
||||
if pending_anchor.is_none() {
|
||||
pending_anchor = extract_id_from_tag(tag_content);
|
||||
}
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
"/p" | "/div" | "/blockquote" => {
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
pending_anchor = None;
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
"/h1" | "/h2" | "/h3" | "/h4" | "/h5" | "/h6" => {
|
||||
heading_level = None;
|
||||
pending_anchor = None;
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
_ => {
|
||||
if pending_anchor.is_none() {
|
||||
pending_anchor = extract_id_from_tag(tag_content);
|
||||
}
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse 3+ consecutive newlines into 2
|
||||
let out = out.trim();
|
||||
let mut final_out = String::with_capacity(out.len());
|
||||
let mut nl_count = 0usize;
|
||||
for c in out.chars() {
|
||||
if c == '\n' {
|
||||
nl_count += 1;
|
||||
if nl_count <= 2 {
|
||||
final_out.push(c);
|
||||
}
|
||||
} else {
|
||||
nl_count = 0;
|
||||
final_out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
final_out
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TocEntry {
|
||||
pub label: String,
|
||||
pub section: usize,
|
||||
pub anchor: Option<String>,
|
||||
pub children: Vec<TocEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContentBlock {
|
||||
pub text: String,
|
||||
pub heading_level: u8, // 0 = body, 1-6 = h1-h6
|
||||
pub anchor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Section {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
/// Populated by pagination algorithm (pre-computed char offsets for page boundaries)
|
||||
pub pages: Vec<usize>,
|
||||
pub blocks: Vec<ContentBlock>,
|
||||
pub page_block_ranges: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BookLayout {
|
||||
Reflowable,
|
||||
FixedLayout,
|
||||
}
|
||||
|
||||
impl BookLayout {
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
BookLayout::Reflowable => "重排",
|
||||
BookLayout::FixedLayout => "固定",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -52,21 +262,59 @@ pub struct Book {
|
||||
pub title: String,
|
||||
pub author: String,
|
||||
pub cover: Option<Vec<u8>>,
|
||||
pub layout: BookLayout,
|
||||
pub sections: Vec<Section>,
|
||||
pub toc: Vec<TocEntry>,
|
||||
}
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn detect_layout(doc: &mut epub::doc::EpubDoc<std::io::BufReader<std::fs::File>>) -> BookLayout {
|
||||
if let Some(vals) = doc.metadata.get("rendition:layout") {
|
||||
if vals.iter().any(|v| v == "pre-paginated") {
|
||||
return BookLayout::FixedLayout;
|
||||
}
|
||||
}
|
||||
if let Ok(opf) = doc.get_resource_str_by_path(&doc.root_file.clone()) {
|
||||
if opf.contains("rendition:layout") && opf.contains("pre-paginated") {
|
||||
return BookLayout::FixedLayout;
|
||||
}
|
||||
if opf.contains("rendition:layout-pre-paginated") {
|
||||
return BookLayout::FixedLayout;
|
||||
}
|
||||
}
|
||||
BookLayout::Reflowable
|
||||
}
|
||||
|
||||
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 layout = detect_layout(&mut doc);
|
||||
|
||||
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 spine: Vec<String> = doc.spine.iter()
|
||||
.filter(|id| {
|
||||
if id.as_str() == "nav" { return false; }
|
||||
if let Some((path, _)) = doc.resources.get(*id) {
|
||||
let path_str = path.to_string_lossy().to_lowercase();
|
||||
if path_str.ends_with("nav.xhtml") || path_str.ends_with("nav.html") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let spine_paths: Vec<String> = spine.iter().map(|id| {
|
||||
doc.resources.get(id)
|
||||
.map(|(path, _)| path.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| id.clone())
|
||||
}).collect();
|
||||
|
||||
let mut sections = Vec::new();
|
||||
for (i, href) in spine.iter().enumerate() {
|
||||
@@ -78,14 +326,15 @@ pub fn load_epub(path: impl AsRef<Path>) -> Result<Book, String> {
|
||||
sections.push(Section {
|
||||
title,
|
||||
content: text,
|
||||
pages: Vec::new(),
|
||||
blocks: Vec::new(),
|
||||
page_block_ranges: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw_toc = std::mem::take(&mut doc.toc);
|
||||
let toc = build_toc(&raw_toc, &spine);
|
||||
let toc = build_toc(&raw_toc, &spine, &spine_paths);
|
||||
|
||||
Ok(Book { title, author, cover, sections, toc })
|
||||
Ok(Book { title, author, cover, layout, sections, toc })
|
||||
}
|
||||
|
||||
fn extract_title(html: &str) -> Option<String> {
|
||||
@@ -107,23 +356,58 @@ fn extract_title(html: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_filename(path: &str) -> &str {
|
||||
let path = path.trim_end_matches('/').trim_end_matches('\\');
|
||||
path.rsplit(&['/', '\\'][..]).next().unwrap_or(path)
|
||||
}
|
||||
|
||||
fn extract_fragment(path: &str) -> Option<String> {
|
||||
if let Some(hash_pos) = path.find('#') {
|
||||
let fragment = &path[hash_pos + 1..];
|
||||
if !fragment.is_empty() {
|
||||
Some(fragment.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn build_toc(
|
||||
entries: &[epub::doc::NavPoint],
|
||||
spine: &[String],
|
||||
spine_paths: &[String],
|
||||
) -> Vec<TocEntry> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let content_str = e.content.to_string_lossy();
|
||||
let section = spine
|
||||
let anchor = extract_fragment(&content_str);
|
||||
let content_file = extract_filename(&content_str);
|
||||
let section = spine_paths
|
||||
.iter()
|
||||
.position(|s| content_str.contains(s.trim_end_matches('/')))
|
||||
// unwrap_or(0) is safe: a real TOC entry should always match a spine item
|
||||
.position(|s| {
|
||||
let spine_file = extract_filename(s);
|
||||
if spine_file == content_file {
|
||||
return true;
|
||||
}
|
||||
content_str.contains(s.as_str()) || s.contains(content_str.as_ref())
|
||||
})
|
||||
.or_else(|| {
|
||||
spine.iter().position(|s| {
|
||||
let spine_file = extract_filename(s);
|
||||
spine_file == content_file
|
||||
|| content_str.contains(s.as_str())
|
||||
|| s.contains(content_str.as_ref())
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
TocEntry {
|
||||
label: e.label.clone(),
|
||||
section,
|
||||
children: build_toc(&e.children, spine),
|
||||
anchor,
|
||||
children: build_toc(&e.children, spine, spine_paths),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -196,9 +480,126 @@ mod tests {
|
||||
assert_eq!(extract_title(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_plain_paragraphs() {
|
||||
let html = "<p>第一段</p><p>第二段</p>";
|
||||
let result = strip_html(html);
|
||||
assert!(result.contains("第一段"));
|
||||
assert!(result.contains("第二段"));
|
||||
assert!(result.contains('\n'));
|
||||
assert!(result.ends_with("第二段"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_plain_heading() {
|
||||
let html = "<h1>标题</h1><p>正文</p>";
|
||||
let result = strip_html(html);
|
||||
assert!(result.contains("标题"));
|
||||
assert!(result.contains("正文"));
|
||||
assert!(result.contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_plain_list() {
|
||||
let html = "<ul><li>项目一</li><li>项目二</li></ul>";
|
||||
let result = strip_html(html);
|
||||
assert!(result.starts_with("- "));
|
||||
assert!(result.contains("项目一"));
|
||||
assert!(result.contains("项目二"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_plain_br() {
|
||||
let html = "第一行<br>第二行";
|
||||
let result = strip_html(html);
|
||||
assert_eq!(result, "第一行\n第二行");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_plain_skip_script() {
|
||||
let html = "<p>正文</p><script>var x=1;</script><p>更多正文</p>";
|
||||
let result = strip_html(html);
|
||||
assert!(result.contains("正文"));
|
||||
assert!(result.contains("更多正文"));
|
||||
assert!(!result.contains("var x=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_plain_line_break_collapse() {
|
||||
let html = "<p>段一</p><p>段二</p><p>段三</p>";
|
||||
let result = strip_html(html);
|
||||
let non_empty: Vec<&str> = result.lines().filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(non_empty.len(), 3);
|
||||
assert_eq!(non_empty[0], "段一");
|
||||
assert_eq!(non_empty[1], "段二");
|
||||
assert_eq!(non_empty[2], "段三");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_toc_empty() {
|
||||
let toc = build_toc(&[], &[]);
|
||||
let toc = build_toc(&[], &[], &[]);
|
||||
assert!(toc.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_sample_epub_nav_filtered() {
|
||||
let book = load_epub("sample-short.epub").expect("Failed to load sample epub");
|
||||
// Nav document is filtered out, leaving only chapter_0 as the single section
|
||||
assert_eq!(book.sections.len(), 1);
|
||||
assert_eq!(book.sections[0].title, "Understanding Digital Formats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toc_section_bounds() {
|
||||
let book = load_epub("sample-short.epub").expect("Failed to load sample epub");
|
||||
// All TOC section indices should be within sections range
|
||||
fn check_bounds(entries: &[TocEntry], max: usize) {
|
||||
for e in entries {
|
||||
assert!(e.section < max, "TOC entry '{}' maps to section {} but only {} sections exist", e.label, e.section, max);
|
||||
check_bounds(&e.children, max);
|
||||
}
|
||||
}
|
||||
check_bounds(&book.toc, book.sections.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_toc_filename_matching() {
|
||||
use epub::doc::NavPoint;
|
||||
let spine = vec![
|
||||
"OEBPS/Text/chapter1.xhtml".to_string(),
|
||||
"OEBPS/Text/chapter2.xhtml".to_string(),
|
||||
];
|
||||
let nav_points = vec![
|
||||
NavPoint {
|
||||
label: "Chapter 2".to_string(),
|
||||
content: std::path::PathBuf::from("Text/chapter2.xhtml"),
|
||||
play_order: 1,
|
||||
children: vec![],
|
||||
},
|
||||
];
|
||||
let toc = build_toc(&nav_points, &spine, &spine);
|
||||
assert_eq!(toc.len(), 1);
|
||||
assert_eq!(toc[0].section, 1); // maps to spine index 1
|
||||
assert_eq!(toc[0].label, "Chapter 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_toc_exact_path_match() {
|
||||
use epub::doc::NavPoint;
|
||||
let spine = vec![
|
||||
"chapter1.xhtml".to_string(),
|
||||
"chapter2.xhtml".to_string(),
|
||||
];
|
||||
let nav_points = vec![
|
||||
NavPoint {
|
||||
label: "Chapter 1".to_string(),
|
||||
content: std::path::PathBuf::from("chapter1.xhtml"),
|
||||
play_order: 1,
|
||||
children: vec![],
|
||||
},
|
||||
];
|
||||
let toc = build_toc(&nav_points, &spine, &spine);
|
||||
assert_eq!(toc.len(), 1);
|
||||
assert_eq!(toc[0].section, 0);
|
||||
}
|
||||
}
|
||||
|
||||
89
src/font.rs
89
src/font.rs
@@ -1,18 +1,75 @@
|
||||
pub fn setup_fonts(ctx: &eframe::egui::Context) {
|
||||
let mut fonts = eframe::egui::FontDefinitions::default();
|
||||
use eframe::egui;
|
||||
use std::path::PathBuf;
|
||||
|
||||
let font_data = include_bytes!("../fonts/NotoSansSC-Regular.ttf");
|
||||
fonts.font_data.insert(
|
||||
"NotoSansSC".to_string(),
|
||||
eframe::egui::FontData::from_static(font_data).into(),
|
||||
);
|
||||
|
||||
if let Some(proportional) = fonts.families.get_mut(&eframe::egui::FontFamily::Proportional) {
|
||||
proportional.insert(0, "NotoSansSC".to_string());
|
||||
}
|
||||
if let Some(monospace) = fonts.families.get_mut(&eframe::egui::FontFamily::Monospace) {
|
||||
monospace.insert(0, "NotoSansSC".to_string());
|
||||
}
|
||||
|
||||
ctx.set_fonts(fonts);
|
||||
pub struct FontDef {
|
||||
pub display_name: &'static str,
|
||||
pub data_key: &'static str,
|
||||
pub ttf_filename: &'static str,
|
||||
pub embedded: bool,
|
||||
}
|
||||
|
||||
pub const ALL_FONTS: &[FontDef] = &[
|
||||
FontDef { display_name: "思源黑体", data_key: "NotoSansSC", ttf_filename: "NotoSansSC-Regular.ttf", embedded: true },
|
||||
FontDef { display_name: "霞鹜文楷", data_key: "LXGWWenKai", ttf_filename: "LXGWWenKai-Regular.ttf", embedded: false },
|
||||
FontDef { display_name: "思源宋体", data_key: "SourceHanSerifSC", ttf_filename: "SourceHanSerifSC-Regular.otf", embedded: false },
|
||||
];
|
||||
|
||||
pub fn font_display_names() -> Vec<String> {
|
||||
ALL_FONTS.iter().map(|f| f.display_name.to_string()).collect()
|
||||
}
|
||||
|
||||
pub fn setup_fonts(ctx: &egui::Context, selected_name: &str) -> String {
|
||||
let font_def = ALL_FONTS.iter().find(|f| f.display_name == selected_name)
|
||||
.unwrap_or(&ALL_FONTS[0]);
|
||||
|
||||
let (data_key, font_data) = if font_def.embedded {
|
||||
(font_def.data_key.to_owned(), egui::FontData::from_static(include_bytes!("../fonts/NotoSansSC-Regular.ttf")))
|
||||
} else {
|
||||
let fonts_dir = find_fonts_dir();
|
||||
let path = fonts_dir.join(font_def.ttf_filename);
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => (font_def.data_key.to_owned(), egui::FontData::from_owned(bytes)),
|
||||
Err(_) => {
|
||||
eprintln!("Font '{}' not found (looked in {:?}), falling back to 思源黑体", font_def.ttf_filename, fonts_dir);
|
||||
let fallback = &ALL_FONTS[0];
|
||||
(fallback.data_key.to_owned(), egui::FontData::from_static(include_bytes!("../fonts/NotoSansSC-Regular.ttf")))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert(data_key.clone(), font_data.into());
|
||||
if let Some(proportional) = fonts.families.get_mut(&egui::FontFamily::Proportional) {
|
||||
proportional.insert(0, data_key.clone());
|
||||
}
|
||||
if let Some(monospace) = fonts.families.get_mut(&egui::FontFamily::Monospace) {
|
||||
monospace.insert(0, data_key.clone());
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
|
||||
if font_def.embedded || data_key == font_def.data_key {
|
||||
font_def.display_name.to_owned()
|
||||
} else {
|
||||
ALL_FONTS[0].display_name.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn find_fonts_dir() -> PathBuf {
|
||||
let cwd = PathBuf::from("fonts");
|
||||
if cwd.is_dir() {
|
||||
return cwd;
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
let p = parent.join("fonts");
|
||||
if p.is_dir() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
let up = PathBuf::from("../fonts");
|
||||
if up.is_dir() {
|
||||
return up;
|
||||
}
|
||||
PathBuf::from("fonts")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ mod book;
|
||||
mod font;
|
||||
mod persistence;
|
||||
mod reader;
|
||||
mod style;
|
||||
mod texture;
|
||||
mod theme;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
@@ -18,7 +20,6 @@ fn main() -> eframe::Result {
|
||||
"ePub Reader",
|
||||
native_options,
|
||||
Box::new(|cc| {
|
||||
font::setup_fonts(&cc.egui_ctx);
|
||||
Ok(Box::new(app::App::new(cc)))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -25,8 +25,20 @@ 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))
|
||||
let mut settings: Settings = serde_json::from_str(&json)
|
||||
.map_err(|e| format!("解析设置失败: {}", e))?;
|
||||
// Always refresh profiles from code-defined presets so that updated
|
||||
// preset values (font size, spacing, etc.) take effect even when
|
||||
// an older settings.json is present on disk.
|
||||
let preset_names: Vec<String> = crate::style::StyleProfile::presets()
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
settings.profiles = crate::style::StyleProfile::presets();
|
||||
if !preset_names.contains(&settings.active_profile) {
|
||||
settings.active_profile = "Kindle 默认".into();
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
762
src/reader.rs
762
src/reader.rs
@@ -1,30 +1,247 @@
|
||||
use eframe::egui;
|
||||
use crate::book::Book;
|
||||
use crate::theme::Theme;
|
||||
use crate::book::{Book, ContentBlock};
|
||||
use crate::style::{StyleProfile, TextAlignment};
|
||||
use crate::theme::{self, BgType, Theme};
|
||||
|
||||
pub fn recalculate_pages(book: &mut Book, font_size: f32, panel_width: f32, panel_height: f32) {
|
||||
let char_width = font_size * 0.6;
|
||||
let line_height = font_size * 1.5;
|
||||
let chars_per_line = if char_width > 0.0 {
|
||||
(panel_width / char_width).max(1.0) as usize
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let lines_per_page = if line_height > 0.0 {
|
||||
(panel_height / line_height).max(1.0) as usize
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let chars_per_page = chars_per_line * lines_per_page;
|
||||
for section in &mut book.sections {
|
||||
section.pages = calculate_pages(§ion.content, chars_per_page);
|
||||
fn parse_blocks(raw_text: &str) -> Vec<ContentBlock> {
|
||||
let mut blocks = Vec::new();
|
||||
for paragraph in raw_text.split("\n\n") {
|
||||
let trimmed = paragraph.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut heading_level = 0u8;
|
||||
let mut anchor: Option<String> = None;
|
||||
let mut text = trimmed.to_string();
|
||||
if text.contains('\x01') {
|
||||
if let Some(pos) = text.find('\x01') {
|
||||
let rest = &text[pos + 1..];
|
||||
heading_level = rest
|
||||
.chars()
|
||||
.next()
|
||||
.and_then(|c| c.to_digit(10))
|
||||
.unwrap_or(1) as u8;
|
||||
let after_level = &text[pos + 2..];
|
||||
if let Some(anchor_start) = after_level.find('\x03') {
|
||||
if let Some(anchor_end) = after_level.find('\x04') {
|
||||
let anchor_str = &after_level[anchor_start + 1..anchor_end];
|
||||
anchor = Some(anchor_str.to_string());
|
||||
// Remove \x03<anchor>\x04 from text to prevent anchor ID contamination
|
||||
let rm_start = pos + 2 + anchor_start;
|
||||
let rm_end = pos + 2 + anchor_end + 1;
|
||||
text.replace_range(rm_start..rm_end, "");
|
||||
}
|
||||
}
|
||||
text.drain(pos..pos + 2);
|
||||
}
|
||||
text = text.replace('\x02', "");
|
||||
}
|
||||
let text = text.trim().to_string();
|
||||
if !text.is_empty() {
|
||||
blocks.push(ContentBlock { text, heading_level, anchor });
|
||||
}
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
blocks.push(ContentBlock {
|
||||
text: String::new(),
|
||||
heading_level: 0,
|
||||
anchor: None,
|
||||
});
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
fn heading_font_size(base_size: f32, level: u8) -> f32 {
|
||||
match level {
|
||||
1 => base_size * 1.6,
|
||||
2 => base_size * 1.35,
|
||||
3 => base_size * 1.15,
|
||||
4 => base_size * 1.05,
|
||||
_ => base_size,
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_top_spacing(para_spacing: f32, level: u8) -> f32 {
|
||||
match level {
|
||||
1 => para_spacing * 3.5,
|
||||
2 => para_spacing * 3.0,
|
||||
3 => para_spacing * 2.5,
|
||||
4 => para_spacing * 2.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_block_height(
|
||||
ctx: &egui::Context,
|
||||
text: &str,
|
||||
font_size: f32,
|
||||
available_width: f32,
|
||||
) -> f32 {
|
||||
if text.is_empty() || available_width <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
job.text = text.to_string();
|
||||
job.sections = vec![egui::text::LayoutSection {
|
||||
leading_space: 0.0,
|
||||
byte_range: 0..text.len(),
|
||||
format: egui::text::TextFormat {
|
||||
font_id: egui::FontId::proportional(font_size),
|
||||
..Default::default()
|
||||
},
|
||||
}];
|
||||
job.wrap = egui::text::TextWrapping {
|
||||
max_width: available_width,
|
||||
..Default::default()
|
||||
};
|
||||
let galley = ctx.fonts(|f| f.layout_job(job));
|
||||
galley.size().y
|
||||
}
|
||||
|
||||
fn measure_line_height(ctx: &egui::Context, font_size: f32) -> f32 {
|
||||
let test_text = "M";
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
job.text = test_text.to_string();
|
||||
job.sections = vec![egui::text::LayoutSection {
|
||||
leading_space: 0.0,
|
||||
byte_range: 0..test_text.len(),
|
||||
format: egui::text::TextFormat {
|
||||
font_id: egui::FontId::proportional(font_size),
|
||||
..Default::default()
|
||||
},
|
||||
}];
|
||||
job.wrap = egui::text::TextWrapping {
|
||||
max_width: 100000.0,
|
||||
..Default::default()
|
||||
};
|
||||
let galley = ctx.fonts(|f| f.layout_job(job));
|
||||
galley.size().y
|
||||
}
|
||||
|
||||
pub fn recalculate_pages(
|
||||
ctx: &egui::Context,
|
||||
book: &mut Book,
|
||||
font_size: f32,
|
||||
panel_width: f32,
|
||||
panel_height: f32,
|
||||
style: &StyleProfile,
|
||||
) {
|
||||
let available_width = panel_width.max(100.0);
|
||||
let available_height = panel_height.max(100.0);
|
||||
|
||||
let indent_str = if style.first_line_indent > 0.0 {
|
||||
"\u{3000}".repeat(style.first_line_indent as usize)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let line_height = measure_line_height(ctx, font_size);
|
||||
let para_spacing = style.paragraph_spacing.round().max(0.0) as f32 * line_height;
|
||||
|
||||
for section in &mut book.sections {
|
||||
if section.blocks.is_empty() {
|
||||
section.blocks = parse_blocks(§ion.content);
|
||||
}
|
||||
|
||||
let mut page_block_ranges: Vec<(usize, usize)> = Vec::new();
|
||||
let mut page_start_block: usize = 0;
|
||||
let mut current_height: f32 = 0.0;
|
||||
|
||||
for (i, block) in section.blocks.iter().enumerate() {
|
||||
let block_font_size = if block.heading_level > 0 {
|
||||
heading_font_size(font_size, block.heading_level)
|
||||
} else {
|
||||
font_size
|
||||
};
|
||||
|
||||
let display_text = if block.heading_level > 0 || indent_str.is_empty() {
|
||||
block.text.clone()
|
||||
} else {
|
||||
format!("{}{}", indent_str, block.text)
|
||||
};
|
||||
|
||||
let block_height = measure_block_height(ctx, &display_text, block_font_size, available_width);
|
||||
|
||||
let spacing = if i > page_start_block && i > 0 {
|
||||
if block.heading_level > 0 {
|
||||
para_spacing + heading_top_spacing(para_spacing, block.heading_level)
|
||||
} else {
|
||||
para_spacing
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let needed = block_height + spacing;
|
||||
|
||||
if current_height > 0.0 && current_height + needed > available_height && i > page_start_block {
|
||||
page_block_ranges.push((page_start_block, i));
|
||||
page_start_block = i;
|
||||
current_height = block_height;
|
||||
} else {
|
||||
current_height += needed;
|
||||
}
|
||||
|
||||
if block.heading_level == 1 && i + 1 < section.blocks.len() {
|
||||
page_block_ranges.push((page_start_block, i + 1));
|
||||
page_start_block = i + 1;
|
||||
current_height = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
if page_start_block < section.blocks.len() {
|
||||
page_block_ranges.push((page_start_block, section.blocks.len()));
|
||||
}
|
||||
|
||||
if page_block_ranges.is_empty() {
|
||||
page_block_ranges.push((0, section.blocks.len().min(1)));
|
||||
}
|
||||
|
||||
section.page_block_ranges = page_block_ranges;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_page_for_block(section: &crate::book::Section, block_index: usize) -> Option<usize> {
|
||||
for (page_idx, &(start, end)) in section.page_block_ranges.iter().enumerate() {
|
||||
if block_index >= start && block_index < end {
|
||||
return Some(page_idx);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn find_page_by_snippet(section: &crate::book::Section, snippet: &str) -> Option<usize> {
|
||||
let search = snippet.chars().take(30).collect::<String>();
|
||||
for (block_idx, block) in section.blocks.iter().enumerate() {
|
||||
if block.text.contains(&search) {
|
||||
return find_page_for_block(section, block_idx);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn find_page_for_anchor(section: &crate::book::Section, anchor: &str) -> Option<usize> {
|
||||
for (block_idx, block) in section.blocks.iter().enumerate() {
|
||||
if let Some(ref a) = block.anchor {
|
||||
if a == anchor {
|
||||
return find_page_for_block(section, block_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub struct ReaderAction {
|
||||
pub go_back: bool,
|
||||
pub toggle_theme: bool,
|
||||
pub toggle_bookmark: bool,
|
||||
pub switch_bg: Option<BgType>,
|
||||
pub switch_to_profile: Option<String>,
|
||||
pub switch_font: Option<String>,
|
||||
pub page_next: bool,
|
||||
pub page_prev: bool,
|
||||
pub toggle_sidebar: bool,
|
||||
pub jump_to_section: Option<usize>,
|
||||
pub jump_to_anchor: Option<String>,
|
||||
}
|
||||
|
||||
pub fn reading_view(
|
||||
@@ -33,68 +250,161 @@ pub fn reading_view(
|
||||
current_section: &mut usize,
|
||||
current_page: &mut usize,
|
||||
sidebar_open: &mut bool,
|
||||
font_size: &mut f32,
|
||||
style: &mut StyleProfile,
|
||||
theme: &Theme,
|
||||
bg_type: BgType,
|
||||
_file_path: &str,
|
||||
) -> ReaderAction {
|
||||
profile_names: &[String],
|
||||
bookmarks: &[crate::theme::Bookmark],
|
||||
current_font: &str,
|
||||
) -> (ReaderAction, Option<usize>) {
|
||||
let mut action = ReaderAction {
|
||||
go_back: false,
|
||||
toggle_theme: false,
|
||||
toggle_bookmark: false,
|
||||
switch_bg: None,
|
||||
switch_to_profile: None,
|
||||
switch_font: None,
|
||||
page_next: false,
|
||||
page_prev: false,
|
||||
toggle_sidebar: false,
|
||||
jump_to_section: None,
|
||||
jump_to_anchor: None,
|
||||
};
|
||||
let mut jump_to_bookmark: Option<usize> = None;
|
||||
|
||||
let panel_size = ui.available_size();
|
||||
recalculate_pages(book, *font_size, panel_size.x, panel_size.y);
|
||||
let colors = theme::reader_colors(theme);
|
||||
|
||||
let has_bookmark = bookmarks.iter().any(|b| b.section == *current_section && b.page == *current_page);
|
||||
|
||||
let sidebar_tab_id = ui.make_persistent_id("sidebar_tab");
|
||||
let mut sidebar_tab: usize = ui.data_mut(|d| *d.get_temp_mut_or_default::<usize>(sidebar_tab_id));
|
||||
|
||||
let settings_visible_id = ui.make_persistent_id("settings_visible");
|
||||
let mut settings_visible = ui.data_mut(|d| *d.get_temp_mut_or_default::<bool>(settings_visible_id));
|
||||
|
||||
// --- Sidebar (TOC) ---
|
||||
if *sidebar_open {
|
||||
egui::SidePanel::left("toc_sidebar")
|
||||
.resizable(true)
|
||||
.default_width(200.0)
|
||||
.default_width(240.0)
|
||||
.show_inside(ui, |ui| {
|
||||
ui.heading("目录");
|
||||
ui.horizontal(|ui| {
|
||||
let toc_response = ui.selectable_label(sidebar_tab == 0, "📋 目录");
|
||||
let bm_response = ui.selectable_label(sidebar_tab == 1,
|
||||
format!("🔖 书签 ({})", bookmarks.len())
|
||||
);
|
||||
if toc_response.clicked() {
|
||||
sidebar_tab = 0;
|
||||
ui.data_mut(|d| *d.get_temp_mut_or_default::<usize>(sidebar_tab_id) = 0);
|
||||
}
|
||||
if bm_response.clicked() {
|
||||
sidebar_tab = 1;
|
||||
ui.data_mut(|d| *d.get_temp_mut_or_default::<usize>(sidebar_tab_id) = 1);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
render_toc(ui, &book.toc, &book.sections, current_section);
|
||||
if sidebar_tab == 0 {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
if let Some((section, anchor)) = render_toc(ui, &book.toc, *current_section) {
|
||||
action.jump_to_section = Some(section);
|
||||
action.jump_to_anchor = anchor;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
render_bookmarks(ui, bookmarks, &mut jump_to_bookmark);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Top toolbar ---
|
||||
egui::TopBottomPanel::top("reader_toolbar")
|
||||
.show_inside(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("← 返回").clicked() {
|
||||
if ui.button("← 返回").on_hover_text("返回书架").clicked() {
|
||||
action.go_back = true;
|
||||
}
|
||||
if ui.button("☰").on_hover_text("打开/关闭目录").clicked() {
|
||||
*sidebar_open = !*sidebar_open;
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(&book.title);
|
||||
ui.label(format!("《{}》", &book.title));
|
||||
ui.label(format!("[{}]", book.layout.label()));
|
||||
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;
|
||||
let toggle_label = if settings_visible { "⚙ ▲" } else { "⚙" };
|
||||
if ui.button(toggle_label).on_hover_text("显示/隐藏设置").clicked() {
|
||||
ui.ctx().data_mut(|d| {
|
||||
let val = d.get_temp_mut_or_default::<bool>(settings_visible_id);
|
||||
*val = !*val;
|
||||
});
|
||||
settings_visible = !settings_visible;
|
||||
}
|
||||
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;
|
||||
if settings_visible {
|
||||
let (theme_icon, theme_hint) = match theme {
|
||||
Theme::Dark => ("🌞", "切换到浅色主题"),
|
||||
Theme::Light => ("🌙", "切换到夜间主题"),
|
||||
Theme::Sepia => ("📜", "切换到棕褐色主题"),
|
||||
};
|
||||
if ui.button(theme_icon).on_hover_text(theme_hint).clicked() {
|
||||
action.toggle_theme = true;
|
||||
}
|
||||
let bookmark_icon = if has_bookmark { "🔴" } else { "🔖" };
|
||||
let bookmark_hint = if has_bookmark { "移除书签" } else { "添加书签" };
|
||||
if ui.button(bookmark_icon).on_hover_text(bookmark_hint).clicked() {
|
||||
action.toggle_bookmark = true;
|
||||
}
|
||||
if ui.button("A⁻").on_hover_text("缩小字体").clicked() {
|
||||
style.font_size = (style.font_size - 2.0).max(10.0);
|
||||
}
|
||||
if ui.button("A⁺").on_hover_text("放大字体").clicked() {
|
||||
style.font_size = (style.font_size + 2.0).min(48.0);
|
||||
}
|
||||
egui::ComboBox::from_id_salt("profile_selector")
|
||||
.width(110.0)
|
||||
.selected_text(&style.name)
|
||||
.show_ui(ui, |ui| {
|
||||
for name in profile_names {
|
||||
let selected = *name == style.name;
|
||||
if ui.selectable_label(selected, name).clicked() {
|
||||
action.switch_to_profile = Some(name.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt("bg_type_selector")
|
||||
.width(100.0)
|
||||
.selected_text(bg_type.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for &label in BgType::ALL.iter() {
|
||||
let selected = bg_type.label() == label;
|
||||
if ui.selectable_label(selected, label).clicked() {
|
||||
action.switch_bg = Some(theme::BgType::from_label(label));
|
||||
}
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt("font_selector")
|
||||
.width(100.0)
|
||||
.selected_text(current_font)
|
||||
.show_ui(ui, |ui| {
|
||||
for name in crate::font::font_display_names() {
|
||||
let selected = name == current_font;
|
||||
if ui.selectable_label(selected, &name).clicked() {
|
||||
action.switch_font = Some(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bottom progress bar ---
|
||||
let panel_size = ui.available_size();
|
||||
let recalc_width = (panel_size.x - 48.0).max(100.0);
|
||||
let recalc_height = (panel_size.y - 45.0).max(200.0);
|
||||
recalculate_pages(ui.ctx(), book, style.font_size, recalc_width, recalc_height, style);
|
||||
|
||||
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 }
|
||||
let section = &book.sections[*current_section];
|
||||
if section.page_block_ranges.is_empty() { 0 } else { section.page_block_ranges.len() }
|
||||
} else { 0 };
|
||||
|
||||
egui::TopBottomPanel::bottom("reader_progress")
|
||||
@@ -105,140 +415,227 @@ pub fn reading_view(
|
||||
.unwrap_or("");
|
||||
ui.label(section_title);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let prev_enabled = *current_page > 0 || *current_section > 0;
|
||||
let next_enabled = if *current_section < book.sections.len() {
|
||||
*current_page + 1 < book.sections[*current_section].page_block_ranges.len()
|
||||
|| *current_section + 1 < book.sections.len()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let next_btn = egui::Button::new(
|
||||
egui::RichText::new("下一页 ▶").size(13.0)
|
||||
).min_size(egui::vec2(100.0, 28.0));
|
||||
|
||||
let prev_btn = egui::Button::new(
|
||||
egui::RichText::new("◀ 上一页").size(13.0)
|
||||
).min_size(egui::vec2(100.0, 28.0));
|
||||
|
||||
if !next_enabled {
|
||||
ui.add_enabled(false, next_btn);
|
||||
} else if ui.add(next_btn).on_hover_text("下一页 (→键)").clicked() {
|
||||
action.page_next = true;
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
if !prev_enabled {
|
||||
ui.add_enabled(false, prev_btn);
|
||||
} else if ui.add(prev_btn).on_hover_text("上一页 (←键)").clicked() {
|
||||
action.page_prev = true;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let label = if total_pages > 0 {
|
||||
format!("{}/{}", *current_page + 1, total_pages)
|
||||
} else {
|
||||
"1/1".to_string()
|
||||
};
|
||||
ui.label(label);
|
||||
let mut progress = if total_pages > 0 {
|
||||
*current_page as f32 / total_pages as f32
|
||||
} else { 0.0 };
|
||||
if ui.add(egui::Slider::new(&mut progress, 0.0..=1.0).text("")).changed() && total_pages > 0 {
|
||||
*current_page = (progress * total_pages as f32).round() as usize;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Center text area ---
|
||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||
let (rect, response) = ui.allocate_at_least(ui.available_size(), egui::Sense::click());
|
||||
// --- Center text area ---
|
||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
||||
let available = ui.available_size();
|
||||
let (rect, response) = ui.allocate_at_least(available, egui::Sense::click());
|
||||
|
||||
if let Some(section) = book.sections.get(*current_section) {
|
||||
if *current_page < section.pages.len().saturating_sub(1) {
|
||||
let start = section.pages[*current_page];
|
||||
let end = section.pages[*current_page + 1];
|
||||
let text: String = section.content.chars().skip(start).take(end - start).collect();
|
||||
ui.put(rect, |ui: &mut egui::Ui| {
|
||||
let color = match theme {
|
||||
Theme::Dark => egui::Color32::WHITE,
|
||||
Theme::Light => egui::Color32::BLACK,
|
||||
};
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&text).size(*font_size).color(color)
|
||||
).wrap()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
let inset = 24.0;
|
||||
let text_rect = egui::Rect::from_min_size(
|
||||
egui::pos2(rect.min.x + inset, rect.min.y),
|
||||
egui::vec2((rect.width() - inset * 2.0).max(100.0), rect.height()),
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
if let Some(section) = book.sections.get(*current_section) {
|
||||
let max_page = section.page_block_ranges.len().saturating_sub(1);
|
||||
if *current_page > max_page {
|
||||
*current_page = max_page;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(section) = book.sections.get(*current_section) {
|
||||
if *current_page < section.page_block_ranges.len() {
|
||||
let (block_start, block_end) = section.page_block_ranges[*current_page];
|
||||
|
||||
let indent_str = if style.first_line_indent > 0.0 {
|
||||
"\u{3000}".repeat(style.first_line_indent as usize)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let line_height = measure_line_height(ui.ctx(), style.font_size);
|
||||
let para_spacing = style.paragraph_spacing.round().max(0.0) as f32 * line_height;
|
||||
|
||||
let align = match style.alignment {
|
||||
TextAlignment::Left => egui::Align::LEFT,
|
||||
TextAlignment::Center => egui::Align::Center,
|
||||
TextAlignment::Right => egui::Align::RIGHT,
|
||||
};
|
||||
|
||||
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(text_rect), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.with_layout(
|
||||
egui::Layout::top_down(align).with_main_justify(false),
|
||||
|ui| {
|
||||
for i in block_start..block_end {
|
||||
if i > block_start {
|
||||
ui.add_space(para_spacing);
|
||||
}
|
||||
|
||||
let block = §ion.blocks[i];
|
||||
let display_text = if block.heading_level > 0 || indent_str.is_empty() {
|
||||
block.text.clone()
|
||||
} else {
|
||||
format!("{}{}", indent_str, block.text)
|
||||
};
|
||||
|
||||
if display_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if block.heading_level > 0 && i > block_start {
|
||||
ui.add_space(heading_top_spacing(para_spacing, block.heading_level));
|
||||
}
|
||||
|
||||
let block_font_size = if block.heading_level > 0 {
|
||||
heading_font_size(style.font_size, block.heading_level)
|
||||
} else {
|
||||
style.font_size
|
||||
};
|
||||
|
||||
let mut rt = egui::RichText::new(&display_text)
|
||||
.size(block_font_size)
|
||||
.color(colors.text);
|
||||
if block.heading_level >= 1 && block.heading_level <= 4 {
|
||||
rt = rt.strong();
|
||||
}
|
||||
|
||||
let label = egui::Label::new(rt).wrap();
|
||||
if block.heading_level == 1 {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.add(label);
|
||||
});
|
||||
} else {
|
||||
ui.add(label);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if has_bookmark {
|
||||
let painter = ui.painter();
|
||||
let bookmark_pos = egui::pos2(rect.max.x - 30.0, rect.min.y + 10.0);
|
||||
painter.text(
|
||||
bookmark_pos,
|
||||
egui::Align2::RIGHT_TOP,
|
||||
"🔴",
|
||||
egui::FontId::proportional(18.0),
|
||||
egui::Color32::RED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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 {
|
||||
action.page_prev = true;
|
||||
} else if x_ratio > 0.7 {
|
||||
action.page_next = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
|
||||
action.page_next = true;
|
||||
}
|
||||
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.page_prev = true;
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::B)) {
|
||||
action.toggle_sidebar = true;
|
||||
}
|
||||
});
|
||||
|
||||
action
|
||||
(action, jump_to_bookmark)
|
||||
}
|
||||
|
||||
fn render_toc(
|
||||
ui: &mut egui::Ui,
|
||||
entries: &[crate::book::TocEntry],
|
||||
_sections: &[crate::book::Section],
|
||||
current_section: &mut usize,
|
||||
) {
|
||||
current_section: usize,
|
||||
) -> Option<(usize, Option<String>)> {
|
||||
let mut jump: Option<(usize, Option<String>)> = None;
|
||||
for entry in entries {
|
||||
let is_current = entry.section == *current_section;
|
||||
let response = if is_current {
|
||||
ui.colored_label(egui::Color32::YELLOW, &entry.label)
|
||||
} else {
|
||||
ui.label(&entry.label)
|
||||
};
|
||||
let label_text = egui::RichText::new(&entry.label);
|
||||
let response = ui.add(
|
||||
egui::Button::new(label_text)
|
||||
.frame(false)
|
||||
.wrap()
|
||||
);
|
||||
if response.clicked() {
|
||||
*current_section = entry.section;
|
||||
jump = Some((entry.section, entry.anchor.clone()));
|
||||
}
|
||||
let anchor_info = entry.anchor.as_ref().map(|a| format!(" @{a}")).unwrap_or_default();
|
||||
response.on_hover_text(format!("跳转到: {} (章节 {}{})", entry.label, entry.section, anchor_info));
|
||||
if !entry.children.is_empty() {
|
||||
ui.indent(&entry.label, |ui| {
|
||||
render_toc(ui, &entry.children, _sections, current_section);
|
||||
if let Some(s) = render_toc(ui, &entry.children, current_section) {
|
||||
jump = Some(s);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
jump
|
||||
}
|
||||
|
||||
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;
|
||||
fn render_bookmarks(
|
||||
ui: &mut egui::Ui,
|
||||
bookmarks: &[crate::theme::Bookmark],
|
||||
jump_to_bookmark: &mut Option<usize>,
|
||||
) {
|
||||
if bookmarks.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("暂无书签");
|
||||
});
|
||||
ui.label("点击工具栏 🔖 按钮添加书签");
|
||||
return;
|
||||
}
|
||||
|
||||
let total_chars = text.chars().count();
|
||||
if total_chars <= chars_per_page {
|
||||
pages.push(total_chars);
|
||||
return pages;
|
||||
for (idx, bm) in bookmarks.iter().enumerate() {
|
||||
let label_text = egui::RichText::new(&bm.label).size(13.0);
|
||||
if ui.add(
|
||||
egui::Button::new(label_text)
|
||||
.frame(false)
|
||||
.wrap()
|
||||
).on_hover_text("点击跳转到该书签").clicked() {
|
||||
*jump_to_bookmark = Some(idx);
|
||||
}
|
||||
}
|
||||
|
||||
let mut pos = 0;
|
||||
while pos < total_chars {
|
||||
pos = (pos + chars_per_page).min(total_chars);
|
||||
pages.push(pos);
|
||||
}
|
||||
|
||||
pages
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -246,39 +643,80 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pagination_empty() {
|
||||
let pages = calculate_pages("", 100);
|
||||
assert_eq!(pages, vec![0]);
|
||||
fn test_parse_blocks_empty() {
|
||||
let blocks = parse_blocks("");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert!(blocks[0].text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_shorter_than_page() {
|
||||
let pages = calculate_pages("Hello World", 100);
|
||||
assert_eq!(pages, vec![0, 11]);
|
||||
fn test_parse_blocks_single_paragraph() {
|
||||
let blocks = parse_blocks("Hello World");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].text, "Hello World");
|
||||
assert_eq!(blocks[0].heading_level, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_exact_fit() {
|
||||
let pages = calculate_pages("ABCD", 4);
|
||||
assert_eq!(pages, vec![0, 4]);
|
||||
fn test_parse_blocks_multiple_paragraphs() {
|
||||
let blocks = parse_blocks("第一段\n\n第二段\n\n第三段");
|
||||
assert_eq!(blocks.len(), 3);
|
||||
assert_eq!(blocks[0].text, "第一段");
|
||||
assert_eq!(blocks[1].text, "第二段");
|
||||
assert_eq!(blocks[2].text, "第三段");
|
||||
}
|
||||
|
||||
#[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]);
|
||||
fn test_parse_blocks_heading() {
|
||||
let blocks = parse_blocks("\x011标题\x02\n\n正文内容");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(blocks[0].heading_level, 1);
|
||||
assert_eq!(blocks[0].text, "标题");
|
||||
assert_eq!(blocks[1].heading_level, 0);
|
||||
assert_eq!(blocks[1].text, "正文内容");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_single_char() {
|
||||
let pages = calculate_pages("A", 1);
|
||||
assert_eq!(pages, vec![0, 1]);
|
||||
fn test_parse_blocks_heading_levels() {
|
||||
let blocks = parse_blocks("\x011一级\x02\n\n\x012二级\x02\n\n\x013三级\x02\n\n正文");
|
||||
assert_eq!(blocks.len(), 4);
|
||||
assert_eq!(blocks[0].heading_level, 1);
|
||||
assert_eq!(blocks[0].text, "一级");
|
||||
assert_eq!(blocks[1].heading_level, 2);
|
||||
assert_eq!(blocks[1].text, "二级");
|
||||
assert_eq!(blocks[2].heading_level, 3);
|
||||
assert_eq!(blocks[2].text, "三级");
|
||||
assert_eq!(blocks[3].heading_level, 0);
|
||||
assert_eq!(blocks[3].text, "正文");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_zero_chars_per_page() {
|
||||
let pages = calculate_pages("test", 0);
|
||||
assert_eq!(pages, vec![0]);
|
||||
fn test_parse_blocks_extra_newlines() {
|
||||
let blocks = parse_blocks("段一\n\n\n\n段二");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(blocks[0].text, "段一");
|
||||
assert_eq!(blocks[1].text, "段二");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_blocks_heading_with_anchor() {
|
||||
// \x011\x03toc1\x04Chapter 1\x02 → heading_level=1, anchor="toc1", text="Chapter 1"
|
||||
let blocks = parse_blocks("\x011\x03toc1\x04Chapter 1\x02\n\nbody text");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(blocks[0].heading_level, 1);
|
||||
assert_eq!(blocks[0].anchor.as_deref(), Some("toc1"));
|
||||
assert_eq!(blocks[0].text, "Chapter 1");
|
||||
assert_eq!(blocks[1].heading_level, 0);
|
||||
assert_eq!(blocks[1].text, "body text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_blocks_heading_without_anchor() {
|
||||
// \x011Title\x02 → heading_level=1, anchor=None, text="Title"
|
||||
let blocks = parse_blocks("\x011Title\x02");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].heading_level, 1);
|
||||
assert_eq!(blocks[0].anchor, None);
|
||||
assert_eq!(blocks[0].text, "Title");
|
||||
}
|
||||
}
|
||||
|
||||
100
src/style.rs
Normal file
100
src/style.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TextAlignment {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StyleProfile {
|
||||
pub name: String,
|
||||
pub alignment: TextAlignment,
|
||||
pub line_spacing: f32,
|
||||
pub paragraph_spacing: f32,
|
||||
pub first_line_indent: f32,
|
||||
pub font_size: f32,
|
||||
}
|
||||
|
||||
impl StyleProfile {
|
||||
pub fn presets() -> Vec<StyleProfile> {
|
||||
vec![
|
||||
StyleProfile {
|
||||
name: "Kindle 默认".into(),
|
||||
alignment: TextAlignment::Left,
|
||||
line_spacing: 1.8,
|
||||
paragraph_spacing: 0.0,
|
||||
first_line_indent: 2.0,
|
||||
font_size: 20.0,
|
||||
},
|
||||
StyleProfile {
|
||||
name: "紧凑".into(),
|
||||
alignment: TextAlignment::Left,
|
||||
line_spacing: 1.0,
|
||||
paragraph_spacing: 0.0,
|
||||
first_line_indent: 0.0,
|
||||
font_size: 14.0,
|
||||
},
|
||||
StyleProfile {
|
||||
name: "宽松".into(),
|
||||
alignment: TextAlignment::Left,
|
||||
line_spacing: 1.0,
|
||||
paragraph_spacing: 2.0,
|
||||
first_line_indent: 2.0,
|
||||
font_size: 26.0,
|
||||
},
|
||||
StyleProfile {
|
||||
name: "居中".into(),
|
||||
alignment: TextAlignment::Center,
|
||||
line_spacing: 1.0,
|
||||
paragraph_spacing: 0.0,
|
||||
first_line_indent: 0.0,
|
||||
font_size: 20.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn line_height(&self) -> f32 {
|
||||
self.font_size * 1.1 + self.font_size * (self.line_spacing - 1.0).max(0.0) * 0.5
|
||||
}
|
||||
|
||||
pub fn apply_to_text(&self, text: &str) -> String {
|
||||
let indented = apply_indent(text, self.first_line_indent);
|
||||
apply_para_spacing(&indented, self.paragraph_spacing)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn apply_para_spacing(text: &str, spacing: f32) -> String {
|
||||
let blank_lines = spacing.round().max(0.0) as usize;
|
||||
if blank_lines == 0 {
|
||||
return text.to_string(); // 保留 strip_html 输出的 \n\n,即 1 行空白
|
||||
}
|
||||
// spacing 直接表示段落间空白行数(1行=1空行,2行=2空行)
|
||||
let total_nl = blank_lines + 1;
|
||||
let sep = "\n".repeat(total_nl);
|
||||
text.replace("\n\n", &sep)
|
||||
}
|
||||
|
||||
pub fn apply_indent(text: &str, indent_chars: f32) -> String {
|
||||
if indent_chars <= 0.0 {
|
||||
return text.to_string();
|
||||
}
|
||||
let indent = "\u{3000}".repeat(indent_chars as usize);
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut para_start = true;
|
||||
for ch in text.chars() {
|
||||
if ch == '\n' {
|
||||
result.push('\n');
|
||||
para_start = true;
|
||||
} else if para_start {
|
||||
result.push_str(&indent);
|
||||
result.push(ch);
|
||||
para_start = false;
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
107
src/texture.rs
Normal file
107
src/texture.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use eframe::egui;
|
||||
|
||||
fn hash(x: u32, y: u32) -> u32 {
|
||||
let mut h = x.wrapping_mul(374761393) ^ y.wrapping_mul(668265263);
|
||||
h = h.wrapping_mul(h ^ (h >> 13));
|
||||
h ^ (h >> 15)
|
||||
}
|
||||
|
||||
fn paper_noise(x: u32, y: u32) -> u8 {
|
||||
let n1 = hash(x, y);
|
||||
let n2 = hash(x.wrapping_add(137), y.wrapping_add(251));
|
||||
let n3 = hash(x.wrapping_mul(7), y.wrapping_mul(13));
|
||||
((n1 ^ n2.wrapping_add(n3)) % 18) as u8
|
||||
}
|
||||
|
||||
pub fn generate_kraft_paper(ctx: &egui::Context) -> egui::TextureHandle {
|
||||
let size = 128usize;
|
||||
let mut pixels = Vec::with_capacity(size * size);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let n = paper_noise(x as u32, y as u32);
|
||||
let r = 212u8.wrapping_add(n);
|
||||
let g = 194u8.wrapping_add(n);
|
||||
let b = 168u8.wrapping_add(n);
|
||||
pixels.push(egui::Color32::from_rgb(r, g, b));
|
||||
}
|
||||
}
|
||||
let image = egui::ColorImage { size: [size, size], pixels };
|
||||
ctx.load_texture("kraft_paper", image, Default::default())
|
||||
}
|
||||
|
||||
/// 稿纸:浅黄色 + 蓝色横线 + 红色竖线
|
||||
pub fn generate_manuscript(ctx: &egui::Context) -> egui::TextureHandle {
|
||||
let size = 256usize;
|
||||
let mut pixels = Vec::with_capacity(size * size);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
// 浅黄色基底
|
||||
let (r, g, b) = (245u8, 230u8, 195u8);
|
||||
// 横线:每 16 像素一条蓝色虚线
|
||||
let is_hline = y % 16 < 2 && x > 8 && x < size - 8;
|
||||
// 竖线:居中的红色引导线
|
||||
let is_vline = x == size / 2 && y > 16 && y < size - 16;
|
||||
let pixel = if is_vline {
|
||||
egui::Color32::from_rgb(220, 50, 50)
|
||||
} else if is_hline {
|
||||
egui::Color32::from_rgb(100, 100, 200)
|
||||
} else {
|
||||
egui::Color32::from_rgb(r, g, b)
|
||||
};
|
||||
pixels.push(pixel);
|
||||
}
|
||||
}
|
||||
let image = egui::ColorImage { size: [size, size], pixels };
|
||||
ctx.load_texture("manuscript", image, Default::default())
|
||||
}
|
||||
|
||||
/// 作文纸:浅绿色 + 横线网格
|
||||
pub fn generate_composition(ctx: &egui::Context) -> egui::TextureHandle {
|
||||
let size = 256usize;
|
||||
let mut pixels = Vec::with_capacity(size * size);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
// 浅绿色基底
|
||||
let (r, g, b) = (220u8, 235u8, 210u8);
|
||||
// 横线网格:每 16 像素一条灰色线
|
||||
let is_grid = (y % 16 < 1) || (x % 64 < 1);
|
||||
let pixel = if is_grid {
|
||||
egui::Color32::from_rgb(180, 180, 180)
|
||||
} else {
|
||||
egui::Color32::from_rgb(r, g, b)
|
||||
};
|
||||
pixels.push(pixel);
|
||||
}
|
||||
}
|
||||
let image = egui::ColorImage { size: [size, size], pixels };
|
||||
ctx.load_texture("composition", image, Default::default())
|
||||
}
|
||||
|
||||
pub fn draw_tiled_bg(
|
||||
painter: &egui::Painter,
|
||||
rect: egui::Rect,
|
||||
texture: &egui::TextureHandle,
|
||||
) {
|
||||
let tile_size = 256.0f32;
|
||||
let mut ty = rect.min.y;
|
||||
while ty < rect.max.y {
|
||||
let mut tx = rect.min.x;
|
||||
while tx < rect.max.x {
|
||||
let tile_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(tx, ty),
|
||||
egui::pos2(
|
||||
(tx + tile_size).min(rect.max.x),
|
||||
(ty + tile_size).min(rect.max.y),
|
||||
),
|
||||
);
|
||||
painter.image(
|
||||
texture.id(),
|
||||
tile_rect,
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
tx += tile_size;
|
||||
}
|
||||
ty += tile_size;
|
||||
}
|
||||
}
|
||||
254
src/theme.rs
254
src/theme.rs
@@ -1,11 +1,56 @@
|
||||
use eframe::egui;
|
||||
use eframe::egui::Style;
|
||||
use eframe::egui::{Color32, CornerRadius, Stroke, Style, Visuals};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 背景类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum BgType {
|
||||
None,
|
||||
Kraft,
|
||||
Manuscript,
|
||||
Composition,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl Default for BgType {
|
||||
fn default() -> Self {
|
||||
BgType::None
|
||||
}
|
||||
}
|
||||
|
||||
impl BgType {
|
||||
pub const ALL: [&'static str; 5] = ["无", "牛皮纸", "稿纸", "作文纸", "自定义图片"];
|
||||
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
BgType::None => "无",
|
||||
BgType::Kraft => "牛皮纸",
|
||||
BgType::Manuscript => "稿纸",
|
||||
BgType::Composition => "作文纸",
|
||||
BgType::Custom(_) => "自定义图片",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_label(label: &str) -> Self {
|
||||
match label {
|
||||
"牛皮纸" => BgType::Kraft,
|
||||
"稿纸" => BgType::Manuscript,
|
||||
"作文纸" => BgType::Composition,
|
||||
"自定义图片" => BgType::Custom(String::new()),
|
||||
_ => BgType::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_background(&self) -> bool {
|
||||
!matches!(self, BgType::None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Theme {
|
||||
Light,
|
||||
Dark,
|
||||
Sepia,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
@@ -14,16 +59,154 @@ impl Default for Theme {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct ReaderColors {
|
||||
pub bg: Color32,
|
||||
pub text: Color32,
|
||||
pub panel_bg: Color32,
|
||||
}
|
||||
|
||||
pub fn reader_colors(theme: &Theme) -> ReaderColors {
|
||||
match theme {
|
||||
Theme::Light => ReaderColors {
|
||||
bg: Color32::from_rgb(245, 245, 245),
|
||||
text: Color32::from_rgb(33, 33, 33),
|
||||
panel_bg: Color32::from_rgb(255, 255, 255),
|
||||
},
|
||||
Theme::Dark => ReaderColors {
|
||||
bg: Color32::from_rgb(18, 18, 18),
|
||||
text: Color32::from_rgb(224, 224, 224),
|
||||
panel_bg: Color32::from_rgb(30, 30, 30),
|
||||
},
|
||||
Theme::Sepia => ReaderColors {
|
||||
bg: Color32::from_rgb(244, 236, 216),
|
||||
text: Color32::from_rgb(91, 70, 54),
|
||||
panel_bg: Color32::from_rgb(251, 244, 226),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn corner_radius_4() -> CornerRadius {
|
||||
CornerRadius::same(4)
|
||||
}
|
||||
|
||||
fn widget_visuals(
|
||||
bg_fill: Color32,
|
||||
weak_bg_fill: Color32,
|
||||
bg_stroke: Stroke,
|
||||
fg_stroke: Stroke,
|
||||
expansion: f32,
|
||||
) -> egui::style::WidgetVisuals {
|
||||
egui::style::WidgetVisuals {
|
||||
bg_fill,
|
||||
weak_bg_fill,
|
||||
bg_stroke,
|
||||
corner_radius: corner_radius_4(),
|
||||
fg_stroke,
|
||||
expansion,
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
},
|
||||
Theme::Light => {
|
||||
let mut visuals = Visuals::light();
|
||||
visuals.widgets.noninteractive.bg_fill = Color32::from_rgb(250, 250, 250);
|
||||
visuals.window_fill = Color32::from_rgb(250, 250, 250);
|
||||
visuals.panel_fill = Color32::from_rgb(250, 250, 250);
|
||||
visuals.faint_bg_color = Color32::from_rgb(245, 245, 245);
|
||||
visuals.extreme_bg_color = Color32::from_rgb(224, 224, 224);
|
||||
visuals.window_corner_radius = corner_radius_4();
|
||||
visuals.widgets.noninteractive.corner_radius = corner_radius_4();
|
||||
visuals.widgets.inactive.corner_radius = corner_radius_4();
|
||||
visuals.widgets.hovered.corner_radius = corner_radius_4();
|
||||
visuals.widgets.active.corner_radius = corner_radius_4();
|
||||
visuals.selection.bg_fill = Color32::from_rgb(187, 222, 251);
|
||||
Style {
|
||||
visuals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
Theme::Dark => {
|
||||
let mut visuals = Visuals::dark();
|
||||
visuals.widgets.noninteractive.bg_fill = Color32::from_rgb(18, 18, 18);
|
||||
visuals.window_fill = Color32::from_rgb(18, 18, 18);
|
||||
visuals.panel_fill = Color32::from_rgb(18, 18, 18);
|
||||
visuals.faint_bg_color = Color32::from_rgb(24, 24, 24);
|
||||
visuals.extreme_bg_color = Color32::from_rgb(40, 40, 40);
|
||||
visuals.window_corner_radius = corner_radius_4();
|
||||
visuals.widgets.noninteractive.corner_radius = corner_radius_4();
|
||||
visuals.widgets.inactive.corner_radius = corner_radius_4();
|
||||
visuals.widgets.hovered.corner_radius = corner_radius_4();
|
||||
visuals.widgets.active.corner_radius = corner_radius_4();
|
||||
visuals.selection.bg_fill = Color32::from_rgb(30, 85, 135);
|
||||
Style {
|
||||
visuals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
Theme::Sepia => {
|
||||
let visuals = Visuals {
|
||||
dark_mode: false,
|
||||
override_text_color: Some(Color32::from_rgb(91, 70, 54)),
|
||||
window_fill: Color32::from_rgb(244, 236, 216),
|
||||
panel_fill: Color32::from_rgb(244, 236, 216),
|
||||
faint_bg_color: Color32::from_rgb(235, 225, 200),
|
||||
extreme_bg_color: Color32::from_rgb(220, 208, 180),
|
||||
code_bg_color: Color32::from_rgb(235, 225, 200),
|
||||
warn_fg_color: Color32::from_rgb(180, 120, 60),
|
||||
error_fg_color: Color32::from_rgb(200, 60, 40),
|
||||
hyperlink_color: Color32::from_rgb(139, 69, 19),
|
||||
selection: egui::style::Selection {
|
||||
bg_fill: Color32::from_rgb(210, 180, 140),
|
||||
stroke: Stroke::new(1.0, Color32::from_rgb(180, 150, 110)),
|
||||
},
|
||||
widgets: egui::style::Widgets {
|
||||
noninteractive: widget_visuals(
|
||||
Color32::from_rgb(251, 244, 226),
|
||||
Color32::from_rgb(244, 236, 216),
|
||||
Stroke::new(1.0, Color32::from_rgb(180, 160, 130)),
|
||||
Stroke::new(1.0, Color32::from_rgb(91, 70, 54)),
|
||||
0.0,
|
||||
),
|
||||
inactive: widget_visuals(
|
||||
Color32::from_rgb(210, 180, 140),
|
||||
Color32::from_rgb(200, 175, 135),
|
||||
Stroke::new(1.0, Color32::from_rgb(160, 130, 100)),
|
||||
Stroke::new(1.0, Color32::from_rgb(91, 70, 54)),
|
||||
0.0,
|
||||
),
|
||||
hovered: widget_visuals(
|
||||
Color32::from_rgb(220, 190, 150),
|
||||
Color32::from_rgb(210, 185, 145),
|
||||
Stroke::new(1.0, Color32::from_rgb(170, 140, 110)),
|
||||
Stroke::new(1.5, Color32::from_rgb(70, 50, 35)),
|
||||
1.0,
|
||||
),
|
||||
active: widget_visuals(
|
||||
Color32::from_rgb(190, 160, 120),
|
||||
Color32::from_rgb(180, 150, 110),
|
||||
Stroke::new(1.0, Color32::from_rgb(140, 110, 80)),
|
||||
Stroke::new(2.0, Color32::from_rgb(50, 35, 20)),
|
||||
1.0,
|
||||
),
|
||||
open: widget_visuals(
|
||||
Color32::from_rgb(200, 170, 130),
|
||||
Color32::from_rgb(190, 160, 125),
|
||||
Stroke::new(1.0, Color32::from_rgb(150, 120, 90)),
|
||||
Stroke::new(2.0, Color32::from_rgb(50, 35, 20)),
|
||||
0.0,
|
||||
),
|
||||
},
|
||||
window_corner_radius: corner_radius_4(),
|
||||
window_shadow: egui::epaint::Shadow::NONE,
|
||||
..Visuals::light()
|
||||
};
|
||||
Style {
|
||||
visuals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +214,14 @@ pub fn create_style(theme: &Theme) -> Style {
|
||||
pub struct Settings {
|
||||
pub font_size: f32,
|
||||
pub theme: Theme,
|
||||
pub bg_type: BgType,
|
||||
pub active_profile: String,
|
||||
pub profiles: Vec<crate::style::StyleProfile>,
|
||||
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)>,
|
||||
pub font_name: String,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -42,18 +229,24 @@ impl Default for Settings {
|
||||
Self {
|
||||
font_size: 20.0,
|
||||
theme: Theme::Light,
|
||||
bg_type: BgType::None,
|
||||
active_profile: "Kindle 默认".into(),
|
||||
profiles: crate::style::StyleProfile::presets(),
|
||||
recent_files: Vec::new(),
|
||||
reading_positions: std::collections::HashMap::new(),
|
||||
bookmarks: std::collections::HashMap::new(),
|
||||
window_size: None,
|
||||
font_name: "思源黑体".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadingPosition {
|
||||
pub section: usize,
|
||||
pub page: usize,
|
||||
pub block_index: Option<usize>,
|
||||
pub text_snippet: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -73,6 +266,7 @@ mod tests {
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.font_size, 20.0);
|
||||
assert_eq!(s.theme, Theme::Light);
|
||||
assert_eq!(s.font_name, "思源黑体");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -88,14 +282,48 @@ mod tests {
|
||||
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_create_style_sepia() {
|
||||
let sepia = create_style(&Theme::Sepia);
|
||||
let light = create_style(&Theme::Light);
|
||||
assert_ne!(sepia.visuals.window_fill, light.visuals.window_fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reader_colors() {
|
||||
let light_colors = reader_colors(&Theme::Light);
|
||||
assert!(light_colors.bg.r() > 200);
|
||||
assert!(light_colors.text.r() < 50);
|
||||
|
||||
let dark_colors = reader_colors(&Theme::Dark);
|
||||
assert!(dark_colors.bg.r() < 30);
|
||||
|
||||
let sepia_colors = reader_colors(&Theme::Sepia);
|
||||
assert!(sepia_colors.bg.r() > 200);
|
||||
assert!(sepia_colors.bg.r() < 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_cycle_all() {
|
||||
let themes = [Theme::Light, Theme::Dark, Theme::Sepia];
|
||||
let json = serde_json::to_string(&Theme::Sepia).unwrap();
|
||||
assert_eq!(json, "\"Sepia\"");
|
||||
let restored: Theme = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored, Theme::Sepia);
|
||||
let styles: Vec<_> = themes.iter().map(|t| create_style(t).visuals.window_fill).collect();
|
||||
for i in 0..styles.len() {
|
||||
for j in (i + 1)..styles.len() {
|
||||
assert_ne!(styles[i], styles[j], "themes {i} and {j} should differ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_serialize() {
|
||||
let json = serde_json::to_string(&Theme::Dark).unwrap();
|
||||
@@ -103,4 +331,4 @@ mod tests {
|
||||
let restored: Theme = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored, Theme::Dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user