Compare commits

..

50 Commits

Author SHA1 Message Date
Developer
a4af235eab ui: move sidebar toggle button to left side next to back button 2026-05-24 07:56:13 +08:00
Developer
e4dfc80979 fix: persist settings_visible toggle state to data store 2026-05-24 07:47:07 +08:00
Developer
b8bdd97d80 feat: add settings toggle button to hide/show toolbar buttons 2026-05-24 07:40:21 +08:00
Developer
f95d1c418f ui: remove reading progress slider from bottom panel 2026-05-24 07:34:28 +08:00
Developer
00ea81137b fix: remove faint_bg_color=TRANSPARENT to improve tooltip readability 2026-05-23 19:18:51 +08:00
Developer
0b076a8c26 fix: only h1 headings force page break, not all heading levels 2026-05-23 18:03:52 +08:00
Developer
11385a68dc feat: force page break after heading blocks for clean chapter layout 2026-05-23 14:53:16 +08:00
Developer
dc6e10d10e fix: remove double width inset in recalculate_pages, match rendering width 2026-05-23 14:47:54 +08:00
Developer
2e6ac83759 fix: resolve spine manifest IDs to file paths for TOC section matching 2026-05-23 08:25:05 +08:00
Developer
528d70fc33 fix: TOC anchor navigation - remove anchor text from parsed content
- Fix parse_blocks: after extracting anchor from \\x03..\\x04 markers,
  remove the anchor text too (was contaminating display text)
- When TOC jump occurs, clear pending_anchor to prevent saved position
  from overriding the TOC navigation on first frame
- Add tests for heading with/without anchor parsing
2026-05-22 21:03:56 +08:00
1d2407098c Add anchor-based TOC navigation: parse #fragment anchors, find exact page within section 2026-05-22 17:56:48 +08:00
21e9aba274 Fix TOC issues: add ScrollArea, fix section jump mechanism 2026-05-22 17:28:44 +08:00
Developer
f12297d580 fix: TOC navigation and pagination improvements
- Fix recalculate_pages missing heading_top_spacing in page height calculation
- Improve build_toc path matching: extract filename first, fall back to substring
- Filter out EPUB3 nav.xhtml from content sections
- Skip Windows resource compilation when windres is not available
- Add unit tests for TOC filename matching and nav filtering
2026-05-21 22:32:18 +08:00
8e8ba01336 Add read.ico as windows exe icon resource 2026-05-21 17:29:31 +08:00
Developer
af141b581d 添加release构建文件 2026-05-17 22:20:50 +08:00
Developer
6bd96cd913 改进标题渲染:按层级差异化显示(字号/加粗/间距/对齐) 2026-05-17 22:06:53 +08:00
Developer
afa8ea6ce7 docs: update README with font selection feature and three fonts 2026-05-16 22:31:30 +08:00
Developer
3c54cc1e55 fix: reorder find_fonts_dir to cwd, exe-parent, ../fonts 2026-05-16 22:26:45 +08:00
Developer
4e7768d265 fix: find_fonts_dir also checks ../fonts/ for dev (target/{debug,release}) 2026-05-16 22:16:07 +08:00
Developer
f76c59b6cf feat: font selection with three bundled fonts
- Add FontDef registry (思源黑体 bundled, 霞鹜文楷 + 思源宋体 runtime)
- Runtime font loading from fonts/ dir with fallback to bundled default
- ComboBox in toolbar to switch fonts
- Persist font_name in settings
- Download LXGW WenKai Lite (13MB) and Source Han Serif SC (23MB)
2026-05-16 22:07:52 +08:00
f2b5be312c docs: 更新README,添加main.png截图和项目结构说明 2026-05-16 12:17:25 +08:00
af03d18470 feat: 响应式分页引擎 - 基于egui Galley精确测量替代字符计数 2026-05-16 12:11:26 +08:00
Developer
4add295bfa tweak(pagination): safety_h=1.0 to reduce underfill 2026-05-15 23:35:50 +08:00
Developer
006cb3cd5e fix(style): use additive line_spacing model (font*1.1 + extra) instead of multiplicative 2026-05-15 23:21:45 +08:00
Developer
31efa90a87 fix(reader): skip leading newlines after page split to prevent blank top/bottom 2026-05-15 23:12:52 +08:00
Developer
7602121fda fix(pagination): balance window_back=1line, safety_h=0.96, recalc_height=-45 2026-05-15 22:16:39 +08:00
Developer
cbb0ee373a fix(pagination): shrink window_back to half-line, improve height estimation (-40px, safety 0.98) 2026-05-15 22:11:32 +08:00
Developer
a6e011261d fix(pagination): shrink window_back to 2 lines, reduce default paragraph spacing to 0 2026-05-15 21:57:45 +08:00
Developer
93f529e700 fix(style): spacing directly represents blank lines between paragraphs (1 or 2) 2026-05-15 21:43:30 +08:00
Developer
362acfddbf fix(style): fix paragraph spacing formula, reduce from 2x to linear extra lines 2026-05-15 21:34:19 +08:00
Developer
9d09e991f4 fix(pagination): rewrite calculate_pages to track lines instead of chars, newlines consume vertical height not horizontal 2026-05-15 21:26:34 +08:00
Developer
669650147b feat(layout): detect reflowable vs fixed-layout EPUB, fix pagination overflow 2026-05-15 21:11:57 +08:00
Developer
e2ed63a982 fix(pagination): paginate styled content to account for indent and paragraph spacing 2026-05-15 20:47:31 +08:00
Developer
33ec709a5e fix(pagination): recalc pages before progress bar using current available height 2026-05-15 20:09:54 +08:00
Developer
0d0700cf89 fix(pagination): recalculate with exact text_rect dims inside CentralPanel, validate current_page 2026-05-15 20:05:34 +08:00
Developer
1cb0c2aef2 fix(pagination): use accurate text area dimensions and char_width=1.0 for Chinese, sync pages before toolbar 2026-05-15 20:00:22 +08:00
Developer
4e79181e07 fix(pagination): recalculate pages using actual text area size, fix char_width for Chinese 2026-05-15 19:43:40 +08:00
eecab02ead 修复崩溃问题:字节索引和字符索引不匹配导致的 Option::unwrap() panic 2026-05-15 13:44:46 +08:00
19336d5d34 标题粗体显示:h1-h6 标题在正文中标粗区分 2026-05-15 12:09:11 +08:00
b51ce6853b 修复侧边栏标签页状态无法保持的问题 2026-05-15 11:32:04 +08:00
2df605c864 细化书签功能:正文指示器、侧边栏书签列表、跳转功能 2026-05-15 11:27:22 +08:00
Developer
88f15d307a feat(bg): multi-background support with kraft, manuscript, composition, and custom image 2026-05-14 23:19:14 +08:00
Developer
a82fa6b7b6 fix(styling): fullscreen kraft paper bg, persistence preset refresh, reader cleanup 2026-05-14 22:50:01 +08:00
Developer
0521439281 fix: use extreme style values (14/20/26 font), remove RichText::line_height, fix para spacing threshold 2026-05-14 21:19:15 +08:00
Developer
4655ad5d31 fix: differentiate style presets with wider font/line/paragraph spacing values, apply paragraph_spacing 2026-05-14 21:13:49 +08:00
Developer
515ec0e07d fix: replace profile cycle button with ComboBox for instant style switching 2026-05-14 21:08:31 +08:00
Developer
7d056e2670 feat: add typography style system with profiles, alignment, line spacing, first-line indent 2026-05-14 21:02:34 +08:00
Developer
d69229b1ca feat: add kraft paper tiled background texture for comfortable reading 2026-05-14 20:49:27 +08:00
Developer
16f801cdf8 feat: improve HTML text extraction with paragraph preservation, add reading margins, paragraph-aware pagination 2026-05-14 20:43:29 +08:00
b0071c6617 功能更新: 目录点击跳转、翻页按钮移至底部右侧、所有按钮添加悬停提示 2026-05-14 17:28:40 +08:00
21 changed files with 2639 additions and 293 deletions

View File

@@ -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
View File

@@ -1 +1,9 @@
/target/
# IDE
.idea/
.vscode/
# Windows
Thumbs.db
*.lnk

662
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -4,21 +4,50 @@
## 截图
![主界面](main.png)
启动页 → 选择 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
read.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

1
read.rc Normal file
View File

@@ -0,0 +1 @@
1 ICON "read.ico"

BIN
sample-short.epub Normal file

Binary file not shown.

View File

@@ -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,
&current_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();
}
}

View File

@@ -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);
}
}

View File

@@ -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")
}

View File

@@ -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)))
}),
)

View File

@@ -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)]

View File

@@ -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(&section.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(&section.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 = &section.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
View 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
View 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;
}
}

View File

@@ -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);
}
}
}