diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 569c1e44..b310a263 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: - "packaging/**" - "rfd/**" - "scripts/**" + - "benchmarks/**" - "Cargo.toml" - "Cargo.lock" - "Makefile" @@ -23,6 +24,7 @@ on: - "packaging/**" - "rfd/**" - "scripts/**" + - "benchmarks/**" - "Cargo.toml" - "Cargo.lock" - "Makefile" diff --git a/Cargo.toml b/Cargo.toml index b2639107..725062eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ anyhow = "1" thiserror = "2" bytes = "1" percent-encoding = "2" + +[profile.dev] +opt-level = 1 diff --git a/benchmarks/epub-page-turn/2026-08-17/README.md b/benchmarks/epub-page-turn/2026-08-17/README.md index c441bb04..cca18322 100644 --- a/benchmarks/epub-page-turn/2026-08-17/README.md +++ b/benchmarks/epub-page-turn/2026-08-17/README.md @@ -31,7 +31,8 @@ stabilization, discards five operation warmups, then records 50 samples. The generated books contain 16 chapters so one run can select stable within-chapter and chapter-boundary pairs. Generated archives are byte-stable. The runner fails if a run reports an error, never produces pages, omits one of -the 16 summaries, or records a different sample count. +the 15 summaries, records a different sample count, or exceeds an operation's +p50 or p95 budget below. ## Initial budgets @@ -57,7 +58,6 @@ All values are milliseconds. |---|---|---|---:|---:| | `sample.epub` | Single | Chapter transition | 0.581 | 1.618 | | `sample.epub` | Single | Relayout | 0.432 | 0.662 | -| `sample.epub` | Spread | Chapter transition | 0.684 | 1.699 | | `sample.epub` | Spread | Relayout | 0.366 | 0.598 | | Generated large text | Single | Warm page turn | 1.603 | 2.906 | | Generated large text | Single | Chapter transition | 1.370 | 3.465 | @@ -72,8 +72,9 @@ All values are milliseconds. | Generated large image | Spread | Chapter transition | 0.846 | 2.204 | | Generated large image | Spread | Relayout | 1.223 | 2.010 | -The small checked-in fixture has no within-chapter warm pair, so that cell is -intentionally omitted. Large-text relayout is the dominant native cost but -remains within the initial budget. These numbers are a renderer-comparison -baseline, not a universal guarantee. Repeat the same protocol on released -platforms and under representative power and display conditions. +The small checked-in fixture has no within-chapter warm pair, and its two +chapters can share one spread, so those cells are intentionally omitted. +Large-text relayout is the dominant native cost but remains within the initial +budget. These numbers are a renderer-comparison baseline, not a universal +guarantee. Repeat the same protocol on released platforms and under +representative power and display conditions. diff --git a/benchmarks/epub-page-turn/2026-08-17/run.sh b/benchmarks/epub-page-turn/2026-08-17/run.sh index 7e35844b..63e21aea 100755 --- a/benchmarks/epub-page-turn/2026-08-17/run.sh +++ b/benchmarks/epub-page-turn/2026-08-17/run.sh @@ -38,7 +38,11 @@ run() { # The checked-in sample is intentionally small, so it covers chapter transitions # and relayout. Generated fixtures add stable warm-turn pairs and text/image load. for width in 700 1000; do - run "$root/crates/shosai-core/tests/fixtures/sample.epub" chapter "$width" + # The sample's two chapters can share one spread, so only single-page mode has + # a stable transition between page-turn destinations. + if [[ "$width" == 700 ]]; then + run "$root/crates/shosai-core/tests/fixtures/sample.epub" chapter "$width" + fi run "$root/crates/shosai-core/tests/fixtures/sample.epub" relayout "$width" for fixture in "$fixtures/large-text.epub" "$fixtures/large-image.epub"; do run "$fixture" warm "$width" diff --git a/benchmarks/epub-page-turn/2026-08-17/tests/test_tools.py b/benchmarks/epub-page-turn/2026-08-17/tests/test_tools.py index cd80d8a0..28fa26b8 100644 --- a/benchmarks/epub-page-turn/2026-08-17/tests/test_tools.py +++ b/benchmarks/epub-page-turn/2026-08-17/tests/test_tools.py @@ -65,6 +65,26 @@ def test_validator_rejects_reported_errors(self): with self.assertRaisesRegex(ValueError, "reported an error"): validator.validate(self.complete_log() + "\nperf-error fixture=sample.epub", 50) + def test_validator_rejects_a_latency_budget_regression(self): + content = self.complete_log().replace( + "operation=warm-page-turn fixture=large-image.epub samples=50 p50_ms=1 p95_ms=2", + "operation=warm-page-turn fixture=large-image.epub samples=50 p50_ms=1 p95_ms=17", + 1, + ) + with self.assertRaisesRegex( + ValueError, "p95_ms=17ms budget=16.7ms" + ): + validator.validate(content, 50) + + def test_validator_rejects_missing_or_non_finite_latency(self): + missing = self.complete_log().replace(" p95_ms=2", "", 1) + with self.assertRaisesRegex(ValueError, "invalid p95_ms"): + validator.validate(missing, 50) + + non_finite = self.complete_log().replace("p50_ms=1", "p50_ms=nan", 1) + with self.assertRaisesRegex(ValueError, "invalid p50_ms"): + validator.validate(non_finite, 50) + if __name__ == "__main__": unittest.main() diff --git a/benchmarks/epub-page-turn/2026-08-17/validate-results.py b/benchmarks/epub-page-turn/2026-08-17/validate-results.py index 42e12c5b..f9f021fb 100755 --- a/benchmarks/epub-page-turn/2026-08-17/validate-results.py +++ b/benchmarks/epub-page-turn/2026-08-17/validate-results.py @@ -2,6 +2,7 @@ """Validate that the 2026-08-17 EPUB performance matrix completed.""" import argparse +import math from pathlib import Path import shlex @@ -13,6 +14,11 @@ "chapter": "chapter-transition", "relayout": "relayout", } +OPERATION_BUDGETS_MS = { + "warm-page-turn": {"p50_ms": 8.0, "p95_ms": 16.7}, + "chapter-transition": {"p50_ms": 16.7, "p95_ms": 33.3}, + "relayout": {"p50_ms": 50.0, "p95_ms": 100.0}, +} def fields(line: str) -> dict[str, str]: @@ -22,7 +28,8 @@ def fields(line: str) -> dict[str, str]: def expected_runs() -> set[tuple[str, str, str]]: expected = set() for width in WIDTHS: - expected.add(("sample.epub", "chapter", width)) + if width == "700": + expected.add(("sample.epub", "chapter", width)) expected.add(("sample.epub", "relayout", width)) for fixture in FIXTURES: for action in ACTION_OPERATION: @@ -77,6 +84,21 @@ def validate(content: str, requested_samples: int) -> None: raise ValueError(f"summary does not match run {key}: {summary}") if summary.get("samples") != expected_samples: raise ValueError(f"summary sample count does not match for {key}: {summary}") + budgets = OPERATION_BUDGETS_MS[expected_operation] + for metric, budget in budgets.items(): + try: + value = float(summary.get(metric, "")) + except ValueError as error: + raise ValueError( + f"summary has invalid {metric} for {key}: {summary.get(metric)!r}" + ) from error + if not math.isfinite(value) or value < 0: + raise ValueError(f"summary has invalid {metric} for {key}: {value}") + if value > budget: + raise ValueError( + f"performance budget exceeded for {key}: " + f"{metric}={value:g}ms budget={budget:g}ms" + ) expected = expected_runs() if actual != expected: diff --git a/crates/shosai-app/locales/en-US/main.ftl b/crates/shosai-app/locales/en-US/main.ftl index 5ca0bee7..d3a102f7 100644 --- a/crates/shosai-app/locales/en-US/main.ftl +++ b/crates/shosai-app/locales/en-US/main.ftl @@ -138,6 +138,7 @@ add-first-books = + Add your first books continue-reading = Continue reading search-results = Search results loading-more = Loading more… +opening-document = Opening document… unknown-author = Unknown author not-started = Not started percent = { $percentage }% diff --git a/crates/shosai-app/locales/ja/main.ftl b/crates/shosai-app/locales/ja/main.ftl index cc55a65f..b61f58a6 100644 --- a/crates/shosai-app/locales/ja/main.ftl +++ b/crates/shosai-app/locales/ja/main.ftl @@ -138,6 +138,7 @@ add-first-books = + 最初の本を追加 continue-reading = 読書を続ける search-results = 検索結果 loading-more = さらに読み込み中… +opening-document = ドキュメントを開いています… unknown-author = 著者不明 not-started = 未読 percent = { $percentage }% diff --git a/crates/shosai-app/src/app.rs b/crates/shosai-app/src/app.rs index 3d481a65..9026d0a0 100644 --- a/crates/shosai-app/src/app.rs +++ b/crates/shosai-app/src/app.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -45,7 +45,9 @@ mod perf; pub use dispatch::update; use epub_navigation::*; -use epub_view::{cache_epub_image_handles, continuous_epub_content_view, epub_chapter_view}; +use epub_view::{ + continuous_epub_content_view, decode_epub_images, epub_chapter_view, epub_image_paths, +}; pub use message::Message; fn text<'a>(value: impl iced::widget::text::IntoFragment<'a>) -> iced::widget::Text<'a> { @@ -92,14 +94,14 @@ impl std::fmt::Display for SelectOption { // --------------------------------------------------------------------------- #[derive(Debug, Clone)] -enum OpenDocument { +pub(crate) enum OpenDocument { Pdf(Arc), Epub(Arc), Cbz(Arc), } #[derive(Debug, Clone, PartialEq, Eq)] -enum AppError { +pub(crate) enum AppError { Storage(String), Open { format: &'static str, @@ -182,7 +184,7 @@ fn import_report_error(report: &ImportReport, i18n: &I18n) -> Option { } #[derive(Clone)] -struct RasterImageHandle(image::Handle); +pub(crate) struct RasterImageHandle(image::Handle); impl std::fmt::Debug for RasterImageHandle { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -199,6 +201,29 @@ enum EpubImageHandle { Svg(iced::widget::svg::Handle), } +#[derive(Debug, Clone)] +pub(crate) enum DecodedEpubImage { + Raster { + width: u32, + height: u32, + pixels: Vec, + }, + Svg(Vec), +} + +impl DecodedEpubImage { + fn into_handle(self) -> EpubImageHandle { + match self { + Self::Raster { + width, + height, + pixels, + } => EpubImageHandle::Raster(image::Handle::from_rgba(width, height, pixels)), + Self::Svg(data) => EpubImageHandle::Svg(iced::widget::svg::Handle::from_memory(data)), + } + } +} + impl std::fmt::Debug for EpubImageHandle { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -322,7 +347,10 @@ struct LibraryMovePlan { const LIBRARY_PAGE_SIZE: u32 = 40; const LIBRARY_LOAD_AHEAD_PX: u32 = 600; -const LIBRARY_REFRESH_MIN_DURATION: std::time::Duration = std::time::Duration::from_millis(300); +const LIBRARY_COVER_MAX_WIDTH: u32 = 440; +const LIBRARY_COVER_MAX_HEIGHT: u32 = 420; +const SEARCH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); +const DOCUMENT_OPEN_NOTICE_DELAY: std::time::Duration = std::time::Duration::from_millis(200); const LIBRARY_ACTIVITY_TICK: std::time::Duration = std::time::Duration::from_millis(16); const LIBRARY_ACTIVITY_STEP: f32 = 16.0 / 300.0; const PAGE_CACHE_CAPACITY: usize = 8; @@ -368,8 +396,32 @@ struct ContinuousMeasuredItem { end: usize, } -struct ContinuousItemOperation { +#[derive(Debug)] +struct ContinuousMeasurementIndex { + tab_id: u64, + activation: u64, items: Vec, + item_indexes: HashMap, +} + +impl ContinuousMeasurementIndex { + fn new(tab_id: u64, activation: u64, items: Vec) -> Self { + let item_indexes = items + .iter() + .enumerate() + .map(|(index, item)| (item.id.clone(), index)) + .collect(); + Self { + tab_id, + activation, + items, + item_indexes, + } + } +} + +struct ContinuousItemOperation { + index: Arc, scroll_id: WidgetId, item_bounds: Vec>, content_top: Option, @@ -381,10 +433,10 @@ struct ContinuousItemOperation { } impl ContinuousItemOperation { - fn resolve(items: Vec, scroll_id: WidgetId, offset: f32) -> Self { - let item_count = items.len(); + fn resolve(index: Arc, scroll_id: WidgetId, offset: f32) -> Self { + let item_count = index.items.len(); Self { - items, + index, scroll_id, item_bounds: vec![None; item_count], content_top: None, @@ -397,14 +449,14 @@ impl ContinuousItemOperation { } fn locate( - items: Vec, + index: Arc, scroll_id: WidgetId, target: (usize, usize), current_tail_extent: f32, ) -> Self { - let item_count = items.len(); + let item_count = index.items.len(); Self { - items, + index, scroll_id, item_bounds: vec![None; item_count], content_top: None, @@ -426,7 +478,7 @@ impl operation::Operation<(usize, usize, f32, f32)> for ContinuousItemOperation } fn container(&mut self, id: Option<&WidgetId>, bounds: iced::Rectangle) { - if let Some(index) = id.and_then(|id| self.items.iter().position(|item| item.id == *id)) { + if let Some(index) = id.and_then(|id| self.index.item_indexes.get(id)).copied() { self.item_bounds[index] = Some(bounds); } } @@ -452,7 +504,8 @@ impl operation::Operation<(usize, usize, f32, f32)> for ContinuousItemOperation }; if let Some((target_page, target_offset)) = self.target { let measured = || { - self.items + self.index + .items .iter() .zip(&self.item_bounds) .filter_map(|(item, bounds)| bounds.map(|bounds| (item, bounds))) @@ -489,7 +542,8 @@ impl operation::Operation<(usize, usize, f32, f32)> for ContinuousItemOperation }; let viewport_y = content_top + offset; let measured = || { - self.items + self.index + .items .iter() .zip(&self.item_bounds) .filter_map(|(item, bounds)| bounds.map(|bounds| (item, bounds))) @@ -570,6 +624,11 @@ struct ReaderTab { rendered_facing_page_handle: Option, page_cache: VecDeque<(PageCacheKey, RenderedPage)>, epub_image_handles: HashMap, + epub_images_pending: HashSet, + epub_images_desired: HashSet, + epub_images_failed: HashSet, + epub_image_decode_active: bool, + epub_image_generation: u64, epub_pages: Arc>, epub_layout_key: Option, epub_page: usize, @@ -626,6 +685,7 @@ struct ReadingStateSave { #[derive(Debug)] enum ReadingStateWriterMessage { Save(ReadingStateSave), + Progress { book_id: i64, progress: f64 }, Language(LanguagePreference), Preference(&'static str, String), Flush(oneshot::Sender<()>), @@ -642,6 +702,12 @@ pub(crate) struct PageCacheKey { // State // --------------------------------------------------------------------------- +#[derive(Debug, Clone)] +struct DocumentOpenPreview { + title: String, + cover: Option, +} + #[derive(Debug)] pub struct State { screen: Screen, @@ -663,6 +729,11 @@ pub struct State { page_cache: VecDeque<(PageCacheKey, RenderedPage)>, render_generation: u64, epub_image_handles: HashMap, + epub_images_pending: HashSet, + epub_images_desired: HashSet, + epub_images_failed: HashSet, + epub_image_decode_active: bool, + epub_image_generation: u64, epub_pages: Arc>, epub_layout_key: Option, epub_page: usize, @@ -672,6 +743,7 @@ pub struct State { continuous_visible: BTreeSet, continuous_tail_extent: f32, continuous_activation: u64, + continuous_measurement_index: Option>, next_continuous_request_id: u64, page_input: String, error: Option, @@ -685,6 +757,10 @@ pub struct State { active_tab_id: Option, next_tab_id: u64, open_error: Option, + document_open_generation: u64, + document_opening: bool, + document_open_notice_visible: bool, + document_open_preview: Option, missing_book_id: Option, show_reader_settings: bool, show_reader_more: bool, @@ -714,13 +790,13 @@ pub struct State { // -- Library state -- library_books: Vec, + library_cover_handles: HashMap, library_search: String, library_filter: Option, library_has_more: bool, library_loading: bool, library_activity_progress: f32, library_generation: u64, - library_book_ids: Arc>, library_offset: usize, book_menu: Option, pending_remove_book: Option, @@ -800,6 +876,11 @@ pub fn boot() -> (State, Task) { page_cache: VecDeque::new(), render_generation: 0, epub_image_handles: HashMap::new(), + epub_images_pending: HashSet::new(), + epub_images_desired: HashSet::new(), + epub_images_failed: HashSet::new(), + epub_image_decode_active: false, + epub_image_generation: 0, epub_pages: Arc::new(Vec::new()), epub_layout_key: None, epub_page: 0, @@ -809,6 +890,7 @@ pub fn boot() -> (State, Task) { continuous_visible: BTreeSet::new(), continuous_tail_extent: 0.0, continuous_activation: 0, + continuous_measurement_index: None, next_continuous_request_id: 1, page_input: String::new(), error: None, @@ -822,6 +904,10 @@ pub fn boot() -> (State, Task) { active_tab_id: None, next_tab_id: 1, open_error: None, + document_open_generation: 0, + document_opening: false, + document_open_notice_visible: false, + document_open_preview: None, missing_book_id: None, show_reader_settings: false, show_reader_more: false, @@ -848,13 +934,13 @@ pub fn boot() -> (State, Task) { search_query_generation: 0, library_books: Vec::new(), + library_cover_handles: HashMap::new(), library_search: String::new(), library_filter: None, library_has_more: false, library_loading: true, library_activity_progress: 0.0, library_generation: 0, - library_book_ids: Arc::new(Vec::new()), library_offset: 0, book_menu: None, pending_remove_book: None, @@ -908,14 +994,20 @@ pub fn boot() -> (State, Task) { let initialize = Task::perform( async { let started = std::time::Instant::now(); - let store = ReadingStateStore::open_async() + let store = ReadingStateStore::open_async_deferred_backfill() .await .map_err(|error| error.to_string())?; + let preferences = store.get_prefs_async().await; + let pref_int = |key: &str| { + preferences + .get(key) + .and_then(|value| value.parse::().ok()) + }; let geometry = match ( - store.get_pref_int_async(WINDOW_WIDTH_KEY).await, - store.get_pref_int_async(WINDOW_HEIGHT_KEY).await, - store.get_pref_int_async(WINDOW_X_KEY).await, - store.get_pref_int_async(WINDOW_Y_KEY).await, + pref_int(WINDOW_WIDTH_KEY), + pref_int(WINDOW_HEIGHT_KEY), + pref_int(WINDOW_X_KEY), + pref_int(WINDOW_Y_KEY), ) { (Some(width), Some(height), Some(x), Some(y)) if width >= 480 && height >= 360 => { Some(( @@ -926,14 +1018,11 @@ pub fn boot() -> (State, Task) { _ => None, }; let language_preference = LanguagePreference::from_stored( - store - .get_pref_async(LANGUAGE_PREFERENCE_KEY) - .await - .as_deref(), + preferences.get(LANGUAGE_PREFERENCE_KEY).map(String::as_str), ); - let managed_books_dir = store - .get_pref_async(shosai_core::library::MANAGED_LIBRARY_DIR_PREFERENCE) - .await + let managed_books_dir = preferences + .get(shosai_core::library::MANAGED_LIBRARY_DIR_PREFERENCE) + .cloned() .map(PathBuf::from) .unwrap_or_else(|| store.managed_books_dir()); if managed_books_dir != store.managed_books_dir() { @@ -941,32 +1030,30 @@ pub fn boot() -> (State, Task) { .map_err(|error| error.to_string())?; } let add_book_behavior = AddBookBehavior::from_stored( - store.get_pref_async(ADD_BOOK_BEHAVIOR_KEY).await.as_deref(), + preferences.get(ADD_BOOK_BEHAVIOR_KEY).map(String::as_str), ); let reader_defaults = ReaderDefaults { reading_mode: ReadingMode::from_stored( - store - .get_pref_async(DEFAULT_READING_MODE_KEY) - .await - .as_deref(), + preferences + .get(DEFAULT_READING_MODE_KEY) + .map(String::as_str), ), theme: ReaderTheme::from_stored( - store - .get_pref_async(DEFAULT_READER_THEME_KEY) - .await - .as_deref(), + preferences + .get(DEFAULT_READER_THEME_KEY) + .map(String::as_str), ), epub_font_size: stored_f32( - store.get_pref_async(DEFAULT_EPUB_FONT_SIZE_KEY).await, + preferences.get(DEFAULT_EPUB_FONT_SIZE_KEY).cloned(), 16.0, 8.0..=48.0, ), epub_line_spacing: stored_f32( - store.get_pref_async(DEFAULT_EPUB_LINE_SPACING_KEY).await, + preferences.get(DEFAULT_EPUB_LINE_SPACING_KEY).cloned(), 1.6, 1.0..=2.4, ), - pdf_zoom: match store.get_pref_async(DEFAULT_PDF_ZOOM_KEY).await.as_deref() { + pdf_zoom: match preferences.get(DEFAULT_PDF_ZOOM_KEY).map(String::as_str) { Some("fit-width") => ZoomMode::FitWidth, _ => ZoomMode::FitPage, }, @@ -1007,62 +1094,85 @@ fn load_library_page(state: &mut State, append: bool) -> Task { }; let offset = if append { state.library_offset } else { 0 }; let generation = state.library_generation; - let next_offset = (offset + LIBRARY_PAGE_SIZE as usize).min(state.library_book_ids.len()); - let ids = Arc::clone(&state.library_book_ids); + let search = state.library_search.clone(); + let filter = state.library_filter; state.library_loading = true; Task::perform( async move { - let books = library - .books_by_ids(&ids[offset..next_offset]) + library + .page(Some(&search), filter, LIBRARY_PAGE_SIZE, offset as u32) .await - .unwrap_or_default(); - BookPage { - books, - has_more: next_offset < ids.len(), - } + .unwrap_or(BookPage { + books: Vec::new(), + has_more: false, + }) }, move |page| Message::LibraryLoaded { generation, offset, - next_offset, + next_offset: offset + page.books.len(), page, }, ) } +fn decode_library_covers_task( + generation: u64, + offset: usize, + covers: Vec<(i64, Vec)>, +) -> Task { + if covers.is_empty() { + return Task::none(); + } + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + covers + .into_iter() + .filter_map(|(id, data)| { + decode_library_cover(Some(&data)).map(|cover| (id, cover)) + }) + .collect() + }) + .await + .unwrap_or_default() + }, + move |cover_handles| Message::LibraryCoversLoaded { + generation, + offset, + cover_handles, + }, + ) +} + +fn decode_library_cover(data: Option<&[u8]>) -> Option { + let image = ::image::load_from_memory(data?) + .ok()? + .thumbnail(LIBRARY_COVER_MAX_WIDTH, LIBRARY_COVER_MAX_HEIGHT); + let rgba = image.to_rgba8(); + let (width, height) = rgba.dimensions(); + Some(RasterImageHandle(image::Handle::from_rgba( + width, + height, + rgba.into_raw(), + ))) +} + fn reset_library(state: &mut State) -> Task { state.library_generation = state.library_generation.wrapping_add(1); - state.library_book_ids = Arc::new(Vec::new()); state.library_offset = 0; state.library_has_more = false; state.book_menu = None; state.pending_remove_book = None; state.library_error = None; - let Some(library) = state.library.clone() else { + if state.library.is_none() { state.library_loading = false; return Task::none(); - }; - let generation = state.library_generation; - let search = state.library_search.clone(); - let filter = state.library_filter; + } state.library_loading = true; state.library_activity_progress = 0.0; - - Task::perform( - async move { - let started = std::time::Instant::now(); - let ids = library - .matching_ids(Some(&search), filter) - .await - .unwrap_or_default(); - if let Some(remaining) = LIBRARY_REFRESH_MIN_DURATION.checked_sub(started.elapsed()) { - tokio::time::sleep(remaining).await; - } - ids - }, - move |ids| Message::LibraryIndexLoaded { generation, ids }, - ) + load_library_page(state, false) } fn library_load_sensor_key(state: &State) -> Option<(u64, usize)> { @@ -1098,6 +1208,11 @@ fn capture_reader_tab(state: &State) -> Option { rendered_facing_page_handle: state.rendered_facing_page_handle.clone(), page_cache: state.page_cache.clone(), epub_image_handles: state.epub_image_handles.clone(), + epub_images_pending: state.epub_images_pending.clone(), + epub_images_desired: state.epub_images_desired.clone(), + epub_images_failed: state.epub_images_failed.clone(), + epub_image_decode_active: state.epub_image_decode_active, + epub_image_generation: state.epub_image_generation, epub_pages: state.epub_pages.clone(), epub_layout_key: state.epub_layout_key, epub_page: state.epub_page, @@ -1145,6 +1260,11 @@ fn restore_reader_tab(state: &mut State, tab: ReaderTab) { state.rendered_facing_page_handle = tab.rendered_facing_page_handle; state.page_cache = tab.page_cache; state.epub_image_handles = tab.epub_image_handles; + state.epub_images_pending = tab.epub_images_pending; + state.epub_images_desired = tab.epub_images_desired; + state.epub_images_failed = tab.epub_images_failed; + state.epub_image_decode_active = tab.epub_image_decode_active; + state.epub_image_generation = tab.epub_image_generation; state.epub_pages = tab.epub_pages; state.epub_layout_key = tab.epub_layout_key; state.epub_page = tab.epub_page; @@ -1224,6 +1344,7 @@ fn select_tab(state: &mut State, index: usize) -> Task { let tab = state.tabs[index].clone(); restore_reader_tab(state, tab); state.continuous_activation = state.continuous_activation.wrapping_add(1); + state.continuous_measurement_index = None; state.active_tab = Some(index); state.screen = Screen::Reader; let search_task = if state.show_search_bar && !state.search_query.is_empty() { @@ -1259,7 +1380,8 @@ fn select_tab(state: &mut State, index: usize) -> Task { } else { Task::none() }; - Task::batch([content_task, search_task, bookmarks_task]) + let image_task = load_epub_images_task(state); + Task::batch([content_task, image_task, search_task, bookmarks_task]) } fn close_tab(state: &mut State, index: usize) -> Task { @@ -1282,6 +1404,10 @@ fn close_tab(state: &mut State, index: usize) -> Task { state.rendered_facing_page = None; state.rendered_facing_page_handle = None; state.epub_image_handles.clear(); + state.epub_images_pending.clear(); + state.epub_images_desired.clear(); + state.epub_images_failed.clear(); + state.epub_image_decode_active = false; state.epub_pages = Arc::new(Vec::new()); state.epub_layout_key = None; state.epub_page = 0; @@ -1306,47 +1432,12 @@ fn open_document(state: &mut State, path: PathBuf, book_id: Option) -> Task .tabs .iter() .position(|tab| book_id.is_some() && tab.book_id == book_id || tab.file_path == path) + && state.tabs[index].file_path == path { - if state.tabs[index].file_path != path { - let document = match load_document(&path) { - Ok(document) => document, - Err(error) => { - let performance_task = perf::fail(state, &error.diagnostic()); - state.open_error = Some(error); - return performance_task; - } - }; - let retained_display_title = state.tabs[index].display_title.clone(); - let display_title = relocated_book_title( - book_id, - &document, - &path, - &state.library_books, - &retained_display_title, - ); - save_active_tab(state); - let relocated_tab = state.tabs[index].clone(); - restore_reader_tab(state, relocated_tab); - let retained_zoom = state.zoom; - let retained_page = state.current_page; - let retained_epub_offset = state.epub_offset; - state.active_tab = Some(index); - state.continuous_activation = state.continuous_activation.wrapping_add(1); - state.open_error = None; - state.missing_book_id = None; - install_document(state, path, book_id, document); - state.zoom = retained_zoom; - state.current_page = retained_page.min(state.total_pages.saturating_sub(1)); - state.epub_offset = retained_epub_offset; - state.page_input = format!("{}", state.current_page + 1); - state.display_title = display_title; - let task = refresh_content(state); - if let Some(tab) = capture_reader_tab(state) { - state.tabs[index] = tab; - state.screen = Screen::Reader; - } - return task; - } + state.document_open_generation = state.document_open_generation.wrapping_add(1); + state.document_opening = false; + state.document_open_notice_visible = false; + state.document_open_preview = None; if let Some(book_id) = book_id { state.tabs[index].book_id = Some(book_id); if let Some(title) = state @@ -1365,14 +1456,96 @@ fn open_document(state: &mut State, path: PathBuf, book_id: Option) -> Task } return select_tab(state, index); } - let document = match load_document(&path) { - Ok(document) => document, - Err(error) => { - let performance_task = perf::fail(state, &error.diagnostic()); - state.open_error = Some(error); - return performance_task; + + state.document_open_generation = state.document_open_generation.wrapping_add(1); + let generation = state.document_open_generation; + state.document_opening = true; + state.document_open_notice_visible = false; + let book = + book_id.and_then(|book_id| state.library_books.iter().find(|book| book.id == book_id)); + state.document_open_preview = Some(DocumentOpenPreview { + title: book.map_or_else( + || { + path.file_stem() + .and_then(|title| title.to_str()) + .map(str::to_owned) + .unwrap_or_else(|| state.i18n.text("opening-document")) + }, + |book| book.title.clone(), + ), + cover: book_id.and_then(|book_id| state.library_cover_handles.get(&book_id).cloned()), + }); + state.open_error = None; + state.missing_book_id = None; + let task_path = path.clone(); + let open = Task::perform( + async move { + tokio::task::spawn_blocking(move || load_document(&task_path)) + .await + .unwrap_or_else(|error| { + Err(AppError::Open { + format: "document", + detail: format!("document loader stopped unexpectedly: {error}"), + }) + }) + }, + move |result| Message::DocumentOpened { + generation, + path, + book_id, + result, + }, + ); + let notice = Task::perform( + async move { tokio::time::sleep(DOCUMENT_OPEN_NOTICE_DELAY).await }, + move |_| Message::ShowDocumentOpenNotice(generation), + ); + Task::batch([open, notice]) +} + +fn finish_open_document( + state: &mut State, + path: PathBuf, + book_id: Option, + document: OpenDocument, +) -> Task { + if let Some(index) = state + .tabs + .iter() + .position(|tab| book_id.is_some() && tab.book_id == book_id || tab.file_path == path) + { + let retained_display_title = state.tabs[index].display_title.clone(); + let display_title = relocated_book_title( + book_id, + &document, + &path, + &state.library_books, + &retained_display_title, + ); + save_active_tab(state); + let relocated_tab = state.tabs[index].clone(); + restore_reader_tab(state, relocated_tab); + let retained_zoom = state.zoom; + let retained_page = state.current_page; + let retained_epub_offset = state.epub_offset; + state.active_tab = Some(index); + state.continuous_activation = state.continuous_activation.wrapping_add(1); + state.open_error = None; + state.missing_book_id = None; + install_document(state, path, book_id, document); + state.zoom = retained_zoom; + state.current_page = retained_page.min(state.total_pages.saturating_sub(1)); + state.epub_offset = retained_epub_offset; + state.page_input = format!("{}", state.current_page + 1); + state.display_title = display_title; + let task = refresh_content(state); + if let Some(tab) = capture_reader_tab(state) { + state.tabs[index] = tab; + state.screen = Screen::Reader; } - }; + return task; + } + save_active_tab(state); let tab_id = state.next_tab_id; state.next_tab_id = state.next_tab_id.wrapping_add(1); @@ -1553,7 +1726,12 @@ fn install_document( state.rendered_facing_page = None; state.rendered_facing_page_handle = None; state.page_cache.clear(); + state.epub_image_generation = state.epub_image_generation.wrapping_add(1); state.epub_image_handles.clear(); + state.epub_images_pending.clear(); + state.epub_images_desired.clear(); + state.epub_images_failed.clear(); + state.epub_image_decode_active = false; state.epub_pages = Arc::new(Vec::new()); state.epub_layout_key = None; state.epub_page = 0; @@ -1720,22 +1898,14 @@ fn refresh_content(state: &mut State) -> Task { scroll_to_current_page(state), ]); } - Some(OpenDocument::Epub(doc)) => { + Some(OpenDocument::Epub(_)) => { state.rendered_page = None; state.rendered_page_index = None; state.rendered_page_handle = None; state.rendered_facing_page = None; state.rendered_facing_page_handle = None; - cache_epub_image_handles( - &mut state.epub_image_handles, - doc.presentation() - .chapters() - .iter() - .flat_map(|chapter| chapter.nodes()), - &|path| doc.resource(path).map(|resource| resource.bytes()), - ); state.error = None; - return scroll_to_current_page(state); + return Task::batch([load_epub_images_task(state), scroll_to_current_page(state)]); } Some(OpenDocument::Cbz(_)) => { state.rendered_page = None; @@ -1788,35 +1958,29 @@ fn refresh_content(state: &mut State) -> Task { return Task::batch(tasks); } Some(OpenDocument::Epub(doc)) => { - let page_size = epub_page_size(state); + let doc = Arc::clone(doc); let layout_key = epub_layout_key(state); state.rendered_page = None; state.rendered_page_index = None; state.rendered_page_handle = None; state.rendered_facing_page = None; state.rendered_facing_page_handle = None; - cache_epub_image_handles( - &mut state.epub_image_handles, - doc.presentation() - .chapters() - .iter() - .flat_map(|chapter| chapter.nodes()), - &|path| doc.resource(path).map(|resource| resource.bytes()), - ); state.error = None; - return paginate_epub_task( - tab_id, - generation, - Arc::clone(doc), - layout_key, - state.font_size, - state.line_spacing, - page_size, - ); + return Task::batch([ + paginate_epub_task(tab_id, generation, doc, layout_key, state.current_page), + load_epub_images_task(state), + ]); } Some(OpenDocument::Cbz(doc)) => { let doc = Arc::clone(doc); let pages = paginated_raster_pages(state); + if pages + .iter() + .any(|page| doc.cached_page_size(*page).is_none()) + { + state.error = None; + return load_cbz_dimensions_task(tab_id, generation, doc, pages); + } let scale = paginated_raster_scale(state, &pages); state.error = None; let mut tasks = Vec::new(); @@ -1845,6 +2009,90 @@ fn refresh_content(state: &mut State) -> Task { Task::none() } +fn load_cbz_dimensions_task( + tab_id: u64, + generation: u64, + document: Arc, + pages: Vec, +) -> Task { + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + for page in pages { + document + .page_size(page) + .map_err(|error| error.to_string())?; + } + Ok(()) + }) + .await + .unwrap_or_else(|error| Err(error.to_string())) + }, + move |result| Message::CbzDimensionsLoaded { + tab_id, + generation, + result, + }, + ) +} + +pub(super) fn load_epub_images_task(state: &mut State) -> Task { + let Some(OpenDocument::Epub(document)) = &state.document else { + return Task::none(); + }; + let chapters = document.presentation().chapters(); + if chapters.is_empty() { + return Task::none(); + } + + let first = state.current_page.saturating_sub(1); + let last = state.current_page.saturating_add(1).min(chapters.len() - 1); + let nearby = epub_image_paths( + chapters[first..=last] + .iter() + .flat_map(|chapter| chapter.nodes()), + ); + state + .epub_image_handles + .retain(|path, _| nearby.contains(path)); + state.epub_images_desired = nearby + .into_iter() + .filter(|path| { + !state.epub_image_handles.contains_key(path) && !state.epub_images_failed.contains(path) + }) + .collect(); + if state.epub_image_decode_active { + return Task::none(); + } + + let paths = state + .epub_images_desired + .iter() + .cloned() + .collect::>(); + if paths.is_empty() { + return Task::none(); + } + + state.epub_images_pending = paths.iter().cloned().collect(); + state.epub_image_decode_active = true; + let document = Arc::clone(document); + let tab_id = state.active_tab_id.unwrap_or(0); + let generation = state.epub_image_generation; + Task::perform( + async move { + tokio::task::spawn_blocking(move || decode_epub_images(&document, paths)) + .await + .unwrap_or_default() + }, + move |images| Message::EpubImagesDecoded { + tab_id, + generation, + images, + }, + ) +} + fn epub_uses_spread(state: &State) -> bool { state.reading_mode == ReadingMode::Paginated && matches!(state.document, Some(OpenDocument::Epub(_))) @@ -1935,25 +2183,49 @@ fn paginate_epub_task( generation: u64, document: Arc, layout_key: EpubLayoutKey, - font_size: f32, - line_spacing: f32, - page_size: Size, + current_chapter: usize, ) -> Task { - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - paginate_epub_document(&document, font_size, line_spacing, page_size) + let font_size = f32::from_bits(layout_key.font_size); + let line_spacing = f32::from_bits(layout_key.line_spacing); + let page_size = Size::new( + f32::from_bits(layout_key.width), + f32::from_bits(layout_key.height), + ); + let pagination = + iced::futures::stream::unfold(Some((document, false)), move |state| async move { + let (document, complete) = state?; + let worker_document = Arc::clone(&document); + let pages = tokio::task::spawn_blocking(move || { + if complete { + paginate_epub_document(&worker_document, font_size, line_spacing, page_size) + } else { + let mut budget = EpubPaginationBudget::for_document( + worker_document.presentation().chapters().len(), + ); + paginate_epub_document_chapter( + &worker_document, + current_chapter, + font_size, + line_spacing, + page_size, + &mut budget, + ) + } }) .await - .unwrap_or_default() - }, - move |pages| Message::EpubPaginated { + .unwrap_or_default(); + let next = (!complete).then_some((document, true)); + Some(((complete, pages), next)) + }); + Task::run(pagination, move |(complete, pages)| { + Message::EpubPaginated { tab_id, generation, layout_key, + complete, pages: Arc::new(pages), - }, - ) + } + }) } fn paginate_epub_document( @@ -1965,42 +2237,62 @@ fn paginate_epub_document( let mut pages = Vec::new(); let chapters = document.presentation().chapters(); let mut budget = EpubPaginationBudget::for_document(chapters.len()); - for (chapter_index, presentation) in chapters.iter().enumerate() { + for chapter_index in 0..chapters.len() { if pages.len() >= MAX_EPUB_PAGES { break; } - let nodes = presentation.nodes(); - let source = document - .chapter(chapter_index) - .expect("presentation chapters match source chapters"); - let title = source - .title - .as_deref() - .filter(|title| !content_starts_with_heading(nodes, title)); - pages.extend( - paginate_epub_chapter_with_budget( - nodes, - title, - font_size, - line_spacing, - page_size, - Some(document.fonts()), - &mut budget, - ) - .into_iter() - .enumerate() - .map(|(page_index, nodes)| EpubPage { - chapter: chapter_index, - title: (page_index == 0) - .then(|| title.map(str::to_string)) - .flatten(), - nodes, - }), - ); + pages.extend(paginate_epub_document_chapter( + document, + chapter_index, + font_size, + line_spacing, + page_size, + &mut budget, + )); } pages } +fn paginate_epub_document_chapter( + document: &EpubDoc, + chapter_index: usize, + font_size: f32, + line_spacing: f32, + page_size: Size, + budget: &mut EpubPaginationBudget, +) -> Vec { + let Some(presentation) = document.presentation().chapter(chapter_index) else { + return Vec::new(); + }; + let nodes = presentation.nodes(); + let source = document + .chapter(chapter_index) + .expect("presentation chapters match source chapters"); + let title = source + .title + .as_deref() + .filter(|title| !content_starts_with_heading(nodes, title)); + paginate_epub_chapter_with_budget( + nodes, + title, + font_size, + line_spacing, + page_size, + Some(document.fonts()), + budget, + ) + .into_iter() + .enumerate() + .map(|(page_index, nodes)| EpubPage { + chapter: chapter_index, + title: (page_index == 0) + .then(|| title.map(str::to_string)) + .flatten(), + nodes, + }) + .collect() +} + fn continuous_scroll_id(tab_id: u64, activation: u64) -> iced::widget::Id { iced::widget::Id::from(format!("continuous-reader-{tab_id}-{activation}")) } @@ -2065,7 +2357,27 @@ fn continuous_measured_items( .collect() } -fn scroll_to_current_page(state: &State) -> Task { +fn continuous_measurement_index( + state: &mut State, + tab_id: u64, + activation: u64, +) -> Arc { + if let Some(index) = &state.continuous_measurement_index + && index.tab_id == tab_id + && index.activation == activation + { + return Arc::clone(index); + } + let index = Arc::new(ContinuousMeasurementIndex::new( + tab_id, + activation, + continuous_measured_items(state, tab_id, activation), + )); + state.continuous_measurement_index = Some(Arc::clone(&index)); + index +} + +fn scroll_to_current_page(state: &mut State) -> Task { if state.reading_mode != ReadingMode::Continuous { return Task::none(); } @@ -2073,8 +2385,9 @@ fn scroll_to_current_page(state: &State) -> Task { return Task::none(); }; let activation = state.continuous_activation; + let index = continuous_measurement_index(state, tab_id, activation); iced::advanced::widget::operate(ContinuousItemOperation::locate( - continuous_measured_items(state, tab_id, activation), + index, continuous_scroll_id(tab_id, activation), (state.current_page, state.epub_offset), state.continuous_tail_extent, @@ -2222,6 +2535,7 @@ fn update_window_scale_factor(state: &mut State, scale_factor: f32) -> Task Vec { fn raster_page_size(state: &State, page: usize) -> Option<(f32, f32)> { match &state.document { Some(OpenDocument::Pdf(document)) => document.page_size(page).ok(), - Some(OpenDocument::Cbz(document)) => document.page_size(page).ok(), + Some(OpenDocument::Cbz(document)) => document.cached_page_size(page), _ => None, } } @@ -2638,15 +2952,17 @@ fn save_reading_state(state: &State) { } // Also update library progress so the library sort/order stays current. - // Use a background task to avoid blocking the UI thread on DB writes. - if let (Some(lib), Some(book_id)) = (state.library.clone(), state.book_id) + if let (Some(saves), Some(book_id)) = (&state.reading_state_saves, state.book_id) && state.total_pages > 0 { let progress = (state.current_page + 1) as f64 / state.total_pages as f64; let progress = progress.clamp(0.0, 1.0); - tokio::task::spawn(async move { - let _ = lib.update_progress(book_id, progress).await; - }); + if saves + .send(ReadingStateWriterMessage::Progress { book_id, progress }) + .is_err() + { + eprintln!("warning: reading state writer stopped unexpectedly"); + } } } @@ -2664,9 +2980,11 @@ fn start_reading_state_writer( store: ReadingStateStore, ) -> mpsc::UnboundedSender { let (sender, mut receiver) = mpsc::unbounded_channel::(); + let library = Library::new(store.pool().clone(), store.managed_books_dir()); tokio::spawn(async move { while let Some(first) = receiver.recv().await { let mut pending = HashMap::new(); + let mut progress = HashMap::new(); let mut language = None; let mut preferences = HashMap::new(); let mut flushes = Vec::new(); @@ -2674,6 +2992,12 @@ fn start_reading_state_writer( ReadingStateWriterMessage::Save(save) => { pending.insert((save.book_id, save.path), save.reading); } + ReadingStateWriterMessage::Progress { + book_id, + progress: value, + } => { + progress.insert(book_id, value); + } ReadingStateWriterMessage::Language(preference) => language = Some(preference), ReadingStateWriterMessage::Preference(key, value) => { preferences.insert(key, value); @@ -2685,6 +3009,12 @@ fn start_reading_state_writer( ReadingStateWriterMessage::Save(save) => { pending.insert((save.book_id, save.path), save.reading); } + ReadingStateWriterMessage::Progress { + book_id, + progress: value, + } => { + progress.insert(book_id, value); + } ReadingStateWriterMessage::Language(preference) => { language = Some(preference); } @@ -2705,6 +3035,11 @@ fn start_reading_state_writer( eprintln!("warning: failed to save reading state: {error}"); } } + for (book_id, progress) in progress { + if let Err(error) = library.update_progress(book_id, progress).await { + eprintln!("warning: failed to save library progress: {error}"); + } + } if let Some(preference) = language && let Err(error) = store .set_pref_async(LANGUAGE_PREFERENCE_KEY, preference.stored()) @@ -2911,8 +3246,13 @@ fn uses_compact_reader_layout(width: f32) -> bool { } fn reader_layout(state: &State, compact: bool) -> Element<'_, Message> { - let main_content = reader_surface(state, compact); - let body: Element<'_, Message> = if state.show_bookmarks_panel { + let loading = state.document_open_notice_visible; + let main_content = if loading { + document_opening_view(state) + } else { + reader_surface(state, compact) + }; + let body: Element<'_, Message> = if !loading && state.show_bookmarks_panel { if compact { bookmarks_panel(state, Length::Fill) } else { @@ -2932,15 +3272,15 @@ fn reader_layout(state: &State, compact: bool) -> Element<'_, Message> { let mut layout = column![tabs_view(state), reader_header(state, compact)].spacing(0); - if state.show_reader_settings { + if !loading && state.show_reader_settings { layout = layout.push(reader_settings(state, compact)); } - if state.show_reader_more { + if !loading && state.show_reader_more { layout = layout.push(reader_more_panel(state, compact)); } - if state.show_search_bar { + if !loading && state.show_search_bar { layout = layout.push(search_bar(state, compact)); } @@ -2978,11 +3318,55 @@ fn reader_layout(state: &State, compact: bool) -> Element<'_, Message> { ); } - layout = layout.push(body).push(status_bar(state)); + layout = layout.push(body); + if !loading { + layout = layout.push(status_bar(state)); + } layout.width(Length::Fill).height(Length::Fill).into() } +fn document_opening_view(state: &State) -> Element<'_, Message> { + let preview = state.document_open_preview.as_ref(); + let title = preview + .map(|preview| preview.title.clone()) + .unwrap_or_else(|| state.i18n.text("opening-document")); + let cover: Element<'_, Message> = + if let Some(handle) = preview.and_then(|preview| preview.cover.as_ref()) { + image(handle.0.clone()) + .width(Length::Fixed(140.0)) + .height(Length::Fixed(200.0)) + .content_fit(iced::ContentFit::Contain) + .into() + } else { + cover_placeholder(Length::Fixed(140.0), 200.0, &title) + }; + + center( + column![ + container(cover) + .width(Length::Fixed(140.0)) + .height(Length::Fixed(200.0)) + .style(app_theme::book_cover), + text(title) + .size(16) + .width(Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + text(state.i18n.text("opening-document")) + .size(13) + .color(app_theme::TEXT_MUTED), + ] + .spacing(14) + .align_x(iced::Alignment::Center) + .width(Length::Fill) + .max_width(320), + ) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + fn reader_surface(state: &State, compact: bool) -> Element<'_, Message> { let content = container(content_view(state)) .width(Length::Fill) @@ -3666,7 +4050,7 @@ fn continuous_content_view(state: &State) -> Element<'_, Message> { } else { let page_height = match &state.document { Some(OpenDocument::Pdf(doc)) => doc.page_size(index).ok(), - Some(OpenDocument::Cbz(doc)) => doc.page_size(index).ok(), + Some(OpenDocument::Cbz(doc)) => doc.cached_page_size(index), _ => None, } .map(|(_, height)| height * state.zoom.scale()) @@ -5250,7 +5634,7 @@ fn library_search_input_id() -> iced::widget::Id { fn render_book_card<'a>(state: &'a State, book: &'a Book) -> Element<'a, Message> { let file_path = book.file_path.clone(); let book_id = book.id; - let cover = render_book_cover(book, Length::Fill, 210.0); + let cover = render_book_cover(state, book, Length::Fill, 210.0); let title_text = text(book.title.clone()) .size(13) .wrapping(iced::widget::text::Wrapping::WordOrGlyph); @@ -5386,7 +5770,7 @@ fn render_continue_card<'a>(state: &'a State, book: &'a Book) -> Element<'a, Mes .height(100) .width(Length::Fill); let content = row![ - render_book_cover(book, Length::Fixed(72.0), 100.0), + render_book_cover(state, book, Length::Fixed(72.0), 100.0), details, text(state.i18n.text("continue")) .size(13) @@ -5406,14 +5790,14 @@ fn render_continue_card<'a>(state: &'a State, book: &'a Book) -> Element<'a, Mes .into() } -fn render_book_cover(book: &Book, width: Length, height: f32) -> Element<'_, Message> { - if let Some(ref cover_data) = book.cover - && let Ok(img) = ::image::load_from_memory(cover_data) - { - let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - let handle = image::Handle::from_rgba(w, h, rgba.into_raw()); - return image(handle) +fn render_book_cover<'a>( + state: &'a State, + book: &'a Book, + width: Length, + height: f32, +) -> Element<'a, Message> { + if let Some(handle) = state.library_cover_handles.get(&book.id) { + return image(handle.0.clone()) .width(width) .height(Length::Fixed(height)) .content_fit(iced::ContentFit::Contain) @@ -5422,7 +5806,7 @@ fn render_book_cover(book: &Book, width: Length, height: f32) -> Element<'_, Mes cover_placeholder(width, height, &book.title) } -fn cover_placeholder(width: Length, height: f32, title: &str) -> Element<'_, Message> { +fn cover_placeholder(width: Length, height: f32, title: &str) -> Element<'static, Message> { let label = text(title.chars().take(20).collect::()) .size(14) .color(iced::Color::WHITE); @@ -5571,6 +5955,16 @@ mod tests { use zip::ZipWriter; use zip::write::SimpleFileOptions; + fn open_document_now(state: &mut State, path: PathBuf, book_id: Option) -> Task { + match load_document(&path) { + Ok(document) => finish_open_document(state, path, book_id, document), + Err(error) => { + state.open_error = Some(error); + Task::none() + } + } + } + fn epub_with_chapter(chapter: &[u8]) -> Vec { epub_with_title_and_chapter("Limits", chapter) } @@ -5594,6 +5988,56 @@ mod tests { archive.finish().unwrap().into_inner() } + fn epub_with_image_chapters(chapter_count: usize) -> Vec { + let mut image_bytes = Vec::new(); + ::image::DynamicImage::ImageRgba8(::image::RgbaImage::from_pixel( + 1, + 1, + ::image::Rgba([1, 2, 3, 255]), + )) + .write_to( + &mut Cursor::new(&mut image_bytes), + ::image::ImageFormat::Png, + ) + .unwrap(); + + let mut archive = ZipWriter::new(Cursor::new(Vec::new())); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + archive.start_file("mimetype", options).unwrap(); + archive.write_all(b"application/epub+zip").unwrap(); + archive + .start_file("META-INF/container.xml", options) + .unwrap(); + archive.write_all(br#""#).unwrap(); + + for chapter in 0..chapter_count { + archive + .start_file(format!("OPS/chapter-{chapter}.xhtml"), options) + .unwrap(); + write!( + archive, + "" + ) + .unwrap(); + archive + .start_file(format!("OPS/image-{chapter}.png"), options) + .unwrap(); + archive.write_all(&image_bytes).unwrap(); + } + + archive.start_file("OPS/content.opf", options).unwrap(); + write!(archive, r#"Images"#).unwrap(); + for chapter in 0..chapter_count { + write!(archive, r#""#).unwrap(); + } + archive.write_all(b"").unwrap(); + for chapter in 0..chapter_count { + write!(archive, r#""#).unwrap(); + } + archive.write_all(b"").unwrap(); + archive.finish().unwrap().into_inner() + } + #[test] fn boot_defers_storage_initialization() { let (state, task) = boot(); @@ -5839,8 +6283,9 @@ mod tests { end: 0, }) .collect::>(); + let index = Arc::new(ContinuousMeasurementIndex::new(1, 0, items)); let mut operation = - ContinuousItemOperation::resolve(items.clone(), continuous_scroll_id(1, 0), 500.0); + ContinuousItemOperation::resolve(Arc::clone(&index), continuous_scroll_id(1, 0), 500.0); operation.content_top = Some(100.0); operation.item_bounds = vec![ Some(iced::Rectangle::new( @@ -5863,7 +6308,7 @@ mod tests { )); let mut navigation = - ContinuousItemOperation::locate(items, continuous_scroll_id(1, 0), (1, 0), 0.0); + ContinuousItemOperation::locate(index, continuous_scroll_id(1, 0), (1, 0), 0.0); navigation.content_top = operation.content_top; navigation.item_bounds = operation.item_bounds; navigation.content_height = Some(1000.0); @@ -5887,8 +6332,9 @@ mod tests { Point::new(0.0, 100.0), Size::new(100.0, 1000.0), )); + let index = Arc::new(ContinuousMeasurementIndex::new(1, 0, items)); let mut resolve = - ContinuousItemOperation::resolve(items.clone(), continuous_scroll_id(1, 0), 500.0); + ContinuousItemOperation::resolve(Arc::clone(&index), continuous_scroll_id(1, 0), 500.0); resolve.content_top = Some(100.0); resolve.item_bounds = vec![bounds]; assert!(matches!( @@ -5897,7 +6343,7 @@ mod tests { )); let mut locate = - ContinuousItemOperation::locate(items, continuous_scroll_id(1, 0), (0, 75), 0.0); + ContinuousItemOperation::locate(index, continuous_scroll_id(1, 0), (0, 75), 0.0); locate.content_top = Some(100.0); locate.content_height = Some(1100.0); locate.viewport_height = Some(200.0); @@ -5908,6 +6354,24 @@ mod tests { )); } + #[test] + fn continuous_measurement_index_is_reused_until_layout_invalidation() { + let epub = EpubDoc::from_bytes( + include_bytes!("../../shosai-core/tests/fixtures/sample.epub").to_vec(), + ) + .expect("fixture should be a valid EPUB"); + let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); + + let first = continuous_measurement_index(&mut state, 1, 0); + let second = continuous_measurement_index(&mut state, 1, 0); + + assert!(Arc::ptr_eq(&first, &second)); + invalidate_continuous_layout(&mut state); + let activation = state.continuous_activation; + let rebuilt = continuous_measurement_index(&mut state, 1, activation); + assert!(!Arc::ptr_eq(&first, &rebuilt)); + } + #[test] fn continuous_epub_items_cover_the_shared_search_text_offsets() { let epub = EpubDoc::from_bytes( @@ -5963,6 +6427,7 @@ mod tests { tab_id: state.active_tab_id.unwrap(), generation: state.render_generation, layout_key, + complete: true, pages: Arc::new(pages), }, ); @@ -6104,6 +6569,59 @@ mod tests { assert!(!uses_exact_paginated_raster_size(&state)); } + #[test] + fn continuous_cbz_view_does_not_read_uncached_archive_entries() { + let document = Arc::new( + CbzDoc::open(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../shosai-core/tests/fixtures/sample.cbz" + )) + .expect("fixture should open"), + ); + let mut state = state_with_document(OpenDocument::Cbz(Arc::clone(&document))); + state.reading_mode = ReadingMode::Continuous; + state.total_pages = document.page_count(); + state.continuous_pages = vec![None; state.total_pages]; + + drop(continuous_content_view(&state)); + + assert_eq!(document.cached_page_size(0), None); + assert_eq!(document.cached_page_size(1), None); + assert_eq!(document.cached_page_size(2), None); + } + + #[test] + fn paginated_cbz_loads_dimensions_before_scheduling_a_render() { + let document = Arc::new( + CbzDoc::open(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../shosai-core/tests/fixtures/sample.cbz" + )) + .expect("fixture should open"), + ); + let mut state = state_with_document(OpenDocument::Cbz(Arc::clone(&document))); + state.total_pages = document.page_count(); + + let dimensions = refresh_content(&mut state); + + assert_eq!(dimensions.units(), 1); + assert_eq!(document.cached_page_size(0), None); + assert!(state.rendered_page.is_none()); + + document.page_size(0).unwrap(); + let generation = state.render_generation; + let render = update( + &mut state, + Message::CbzDimensionsLoaded { + tab_id: 1, + generation, + result: Ok(()), + }, + ); + + assert!(render.units() > 0); + } + #[test] fn display_scale_change_invalidates_pdf_rasters_and_schedules_a_rerender() { let pdf = PdfDoc::from_bytes( @@ -6234,6 +6752,9 @@ mod tests { ) .expect("fixture should be a valid CBZ"); let total_pages = cbz.page_count(); + for page in 0..2 { + cbz.page_size(page).unwrap(); + } let mut state = state_with_document(OpenDocument::Cbz(Arc::new(cbz))); state.total_pages = total_pages; state.zoom = ZoomMode::FitPage; @@ -6271,6 +6792,9 @@ mod tests { ) .expect("fixture should be a valid CBZ"); let total_pages = cbz.page_count(); + for page in 0..2 { + cbz.page_size(page).unwrap(); + } let mut state = state_with_document(OpenDocument::Cbz(Arc::new(cbz))); state.total_pages = total_pages; state.zoom = ZoomMode::FitPage; @@ -6308,6 +6832,9 @@ mod tests { ) .expect("fixture should be a valid CBZ"); let total_pages = cbz.page_count(); + for page in 0..2 { + cbz.page_size(page).unwrap(); + } let mut state = state_with_document(OpenDocument::Cbz(Arc::new(cbz))); state.total_pages = total_pages; state.zoom = ZoomMode::FitPage; @@ -6326,6 +6853,7 @@ mod tests { ) .expect("fixture should be a valid CBZ"); let total_pages = cbz.page_count(); + cbz.page_size(0).unwrap(); let mut state = state_with_document(OpenDocument::Cbz(Arc::new(cbz))); state.total_pages = total_pages; state.zoom = ZoomMode::FitPage; @@ -7960,8 +8488,8 @@ mod tests { .unwrap(); let (mut state, _) = boot(); - let _ = open_document(&mut state, epub_path, None); - let _ = open_document(&mut state, pdf_path, None); + let _ = open_document_now(&mut state, epub_path, None); + let _ = open_document_now(&mut state, pdf_path, None); let _ = update(&mut state, Message::DefaultEpubFontSizeUp); let _ = update(&mut state, Message::SelectDefaultEpubLineSpacing(1.8)); let _ = update(&mut state, Message::SelectDefaultPdfFitWidth(true)); @@ -7998,13 +8526,13 @@ mod tests { pdf_zoom: ZoomMode::FitWidth, }; - let _ = open_document(&mut state, epub_path, None); + let _ = open_document_now(&mut state, epub_path, None); assert_eq!(state.reading_mode, ReadingMode::Continuous); assert_eq!(state.theme, ReaderTheme::Sepia); assert_eq!(state.font_size, 20.0); assert_eq!(state.line_spacing, 1.8); - let _ = open_document(&mut state, pdf_path, None); + let _ = open_document_now(&mut state, pdf_path, None); assert_eq!(state.zoom, ZoomMode::FitWidth); } @@ -8233,6 +8761,82 @@ mod tests { assert_eq!(state.library_books.len(), 1); } + #[tokio::test] + async fn library_search_waits_for_the_latest_debounce_before_querying() { + let directory = tempfile::tempdir().unwrap(); + let store = ReadingStateStore::open_at_async(&directory.path().join("state.db")) + .await + .unwrap(); + let (mut state, _) = boot(); + state.library = Some(Library::new( + store.pool().clone(), + store.managed_books_dir(), + )); + state.library_loading = false; + + let debounce = update( + &mut state, + Message::LibrarySearchChanged("machine".to_string()), + ); + let generation = state.library_generation; + + assert!(debounce.units() > 0); + assert!(!state.library_loading); + assert_eq!( + update(&mut state, Message::LibrarySearchDebounced(generation - 1)).units(), + 0 + ); + + let query = update(&mut state, Message::LibrarySearchDebounced(generation)); + assert!(query.units() > 0); + assert!(state.library_loading); + } + + #[test] + fn library_metadata_is_installed_before_cover_decoding_finishes() { + let (mut state, _) = boot(); + let generation = state.library_generation; + let mut book = test_book(1); + book.cover = Some(include_bytes!("../../../assets/shosai-icon.png").to_vec()); + + let cover_task = update( + &mut state, + Message::LibraryLoaded { + generation, + offset: 0, + next_offset: 1, + page: BookPage { + books: vec![book], + has_more: false, + }, + }, + ); + + assert_eq!(cover_task.units(), 1); + assert_eq!(state.library_books.len(), 1); + assert!(state.library_cover_handles.is_empty()); + + let cover = decode_library_cover(Some(include_bytes!("../../../assets/shosai-icon.png"))) + .expect("application icon should decode as a cover"); + let cover_id = cover.0.id(); + let _ = update( + &mut state, + Message::LibraryCoversLoaded { + generation, + offset: 0, + cover_handles: HashMap::from([(1, cover)]), + }, + ); + + assert_eq!(state.library_cover_handles[&1].0.id(), cover_id); + } + + #[test] + fn malformed_library_cover_is_rejected_before_view_construction() { + assert!(decode_library_cover(Some(b"not an image")).is_none()); + assert!(decode_library_cover(None).is_none()); + } + #[test] fn library_activity_advances_only_while_loading() { let (mut state, _) = boot(); @@ -8266,6 +8870,28 @@ mod tests { assert_eq!(state.library_activity_progress, 1.0); } + #[test] + fn initial_library_load_completes_the_activity_bar() { + let (mut state, _) = boot(); + state.library_activity_progress = 0.75; + let generation = state.library_generation; + + let _ = update( + &mut state, + Message::LibraryLoaded { + generation, + offset: 0, + next_offset: 1, + page: BookPage { + books: vec![test_book(1)], + has_more: false, + }, + }, + ); + + assert_eq!(state.library_activity_progress, 1.0); + } + #[test] fn discovery_activity_is_monotonic_and_reaches_completion() { let snapshots = [ @@ -8323,7 +8949,7 @@ mod tests { } #[test] - fn empty_library_index_clears_books_from_the_previous_filter() { + fn empty_first_library_page_clears_books_from_the_previous_filter() { let (mut state, _) = boot(); state.library_generation = 2; state.library_books.push(test_book(1)); @@ -8331,14 +8957,20 @@ mod tests { let _ = update( &mut state, - Message::LibraryIndexLoaded { + Message::LibraryLoaded { generation: 2, - ids: Vec::new(), + offset: 0, + next_offset: 0, + page: BookPage { + books: Vec::new(), + has_more: false, + }, }, ); assert!(state.library_books.is_empty()); assert!(!state.library_loading); + assert!(!state.library_has_more); } #[test] @@ -8350,7 +8982,6 @@ mod tests { let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); state.library_generation = 1; state.library_books.push(test_book(1)); - state.library_book_ids = Arc::new(vec![1, 2]); state.library_offset = 1; state.library_loading = true; @@ -8419,12 +9050,12 @@ mod tests { state.library_loading = false; state.storage_initializing = false; - let _ = open_document(&mut state, original, Some(42)); + let _ = open_document_now(&mut state, original, Some(42)); let old_document = match state.document.as_ref().unwrap() { OpenDocument::Epub(document) => Arc::clone(document), _ => panic!("expected EPUB document"), }; - let _ = open_document(&mut state, replacement.clone(), Some(42)); + let _ = open_document_now(&mut state, replacement.clone(), Some(42)); assert_eq!(state.tabs.len(), 1); assert_eq!(state.active_tab, Some(0)); @@ -8452,7 +9083,7 @@ mod tests { state.library_loading = false; state.storage_initializing = false; - let _ = open_document(&mut state, first, Some(42)); + let _ = open_document_now(&mut state, first, Some(42)); state.current_page = 1; state.epub_offset = 25; state.font_size = 22.0; @@ -8465,13 +9096,13 @@ mod tests { epub_font_size: true, pdf_zoom: false, }; - let _ = open_document(&mut state, second, Some(43)); + let _ = open_document_now(&mut state, second, Some(43)); state.font_size = 12.0; state.line_spacing = 1.2; state.theme = ReaderTheme::Sepia; state.reading_mode = ReadingMode::Paginated; - let _ = open_document(&mut state, relocated, Some(42)); + let _ = open_document_now(&mut state, relocated, Some(42)); assert_eq!(state.active_tab, Some(0)); assert_eq!(state.font_size, 22.0); @@ -8497,10 +9128,10 @@ mod tests { state.library_loading = false; state.storage_initializing = false; - let _ = open_document(&mut state, original, Some(42)); + let _ = open_document_now(&mut state, original, Some(42)); state.zoom = ZoomMode::FitWidth; state.reader_overrides.pdf_zoom = true; - let _ = open_document(&mut state, relocated, Some(42)); + let _ = open_document_now(&mut state, relocated, Some(42)); assert_eq!(state.zoom, ZoomMode::FitWidth); assert!(state.reader_overrides.pdf_zoom); @@ -8522,9 +9153,9 @@ mod tests { book.title = "Retained library title".to_string(); state.library_books = vec![book]; - let _ = open_document(&mut state, original, Some(42)); + let _ = open_document_now(&mut state, original, Some(42)); state.library_books.clear(); - let _ = open_document(&mut state, replacement, Some(42)); + let _ = open_document_now(&mut state, replacement, Some(42)); assert_eq!( state.display_title.as_deref(), @@ -8551,9 +9182,9 @@ mod tests { book.title = "Curated library title".to_string(); state.library_books = vec![book]; - let _ = open_document(&mut state, original, Some(42)); + let _ = open_document_now(&mut state, original, Some(42)); state.library_books.clear(); - let _ = open_document(&mut state, replacement, Some(42)); + let _ = open_document_now(&mut state, replacement, Some(42)); assert_eq!( state.display_title.as_deref(), @@ -8770,6 +9401,7 @@ mod tests { tab_id: 1, generation: 1, layout_key, + complete: true, pages: Arc::new(vec![EpubPage { chapter: 0, title: None, @@ -8799,6 +9431,7 @@ mod tests { tab_id: 1, generation: 2, layout_key, + complete: true, pages: Arc::new(vec![ EpubPage { chapter: 0, @@ -8818,6 +9451,48 @@ mod tests { assert_eq!(state.epub_page, 1); } + #[test] + fn initial_epub_pagination_cannot_replace_a_completed_layout() { + let epub = EpubDoc::from_bytes( + include_bytes!("../../shosai-core/tests/fixtures/sample.epub").to_vec(), + ) + .expect("fixture should be a valid EPUB"); + let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); + let layout_key = epub_layout_key(&state); + let complete_pages = Arc::new(vec![ + EpubPage { + chapter: 0, + title: None, + nodes: Vec::new(), + }, + EpubPage { + chapter: 1, + title: None, + nodes: Vec::new(), + }, + ]); + state.epub_pages = Arc::clone(&complete_pages); + state.epub_layout_key = Some(layout_key); + let generation = state.render_generation; + + let _ = update( + &mut state, + Message::EpubPaginated { + tab_id: 1, + generation, + layout_key, + complete: false, + pages: Arc::new(vec![EpubPage { + chapter: 0, + title: None, + nodes: Vec::new(), + }]), + }, + ); + + assert!(Arc::ptr_eq(&state.epub_pages, &complete_pages)); + } + #[test] fn epub_pagination_completes_for_an_inactive_tab() { let epub = EpubDoc::from_bytes( @@ -8845,6 +9520,7 @@ mod tests { tab_id: 1, generation: 4, layout_key, + complete: true, pages: Arc::new(vec![EpubPage { chapter: 0, title: None, @@ -8885,6 +9561,7 @@ mod tests { tab_id: 1, generation: 4, layout_key: obsolete_layout, + complete: true, pages: Arc::new(vec![EpubPage { chapter: 0, title: None, @@ -8941,6 +9618,75 @@ mod tests { assert!(state.rendered_page.is_none()); } + #[test] + fn document_open_notice_is_delayed_and_scoped_to_the_latest_request() { + let (mut state, _) = boot(); + state.library_books = vec![test_book(42)]; + state.library_cover_handles.insert( + 42, + RasterImageHandle(image::Handle::from_rgba(1, 1, vec![0, 0, 0, 255])), + ); + let first = open_document(&mut state, PathBuf::from("first.epub"), None); + let first_generation = state.document_open_generation; + + assert_eq!(first.units(), 2); + assert!(state.document_opening); + assert!(!state.document_open_notice_visible); + assert_eq!(state.screen, Screen::Library); + assert_eq!( + state + .document_open_preview + .as_ref() + .map(|preview| preview.title.as_str()), + Some("first") + ); + + let _ = open_document(&mut state, PathBuf::from("second.epub"), Some(42)); + let second_generation = state.document_open_generation; + let _ = update( + &mut state, + Message::ShowDocumentOpenNotice(first_generation), + ); + assert!(!state.document_open_notice_visible); + + let _ = update( + &mut state, + Message::ShowDocumentOpenNotice(second_generation), + ); + assert!(state.document_open_notice_visible); + assert_eq!(state.screen, Screen::Reader); + assert_eq!( + state + .document_open_preview + .as_ref() + .map(|preview| preview.title.as_str()), + Some("Book 42") + ); + assert!( + state + .document_open_preview + .as_ref() + .and_then(|preview| preview.cover.as_ref()) + .is_some() + ); + state.library_books.clear(); + state.library_cover_handles.clear(); + drop(document_opening_view(&state)); + + let _ = update( + &mut state, + Message::DocumentOpened { + generation: second_generation, + path: PathBuf::from("second.epub"), + book_id: Some(42), + result: Err(AppError::UnsupportedFormat("epub".to_string())), + }, + ); + assert!(!state.document_opening); + assert!(!state.document_open_notice_visible); + assert!(state.document_open_preview.is_none()); + } + #[test] fn failed_open_preserves_the_active_document_and_render_state() { let cbz = CbzDoc::from_bytes( @@ -8952,15 +9698,30 @@ mod tests { let render_task = refresh_content(&mut state); let old_generation = state.render_generation; let old_document = state.document.clone(); - let open_task = open_document(&mut state, PathBuf::from("unsupported.txt"), None); + let path = PathBuf::from("unsupported.txt"); + let open_task = open_document(&mut state, path.clone(), None); + let open_generation = state.document_open_generation; assert!(render_task.units() > 0); - assert_eq!(open_task.units(), 0); + assert!(open_task.units() > 0); + assert!(state.document_opening); assert_eq!(state.render_generation, old_generation); assert!(matches!( - (&state.document, old_document), + (&state.document, &old_document), (Some(OpenDocument::Cbz(_)), Some(OpenDocument::Cbz(_))) )); + + let _ = update( + &mut state, + Message::DocumentOpened { + generation: open_generation, + path, + book_id: None, + result: Err(AppError::UnsupportedFormat("txt".to_string())), + }, + ); + + assert!(!state.document_opening); assert!(matches!( state.open_error, Some(AppError::UnsupportedFormat(ref format)) if format == "txt" @@ -8990,6 +9751,42 @@ mod tests { ); } + #[test] + fn stale_document_open_completion_cannot_replace_a_newer_request() { + let cbz = CbzDoc::from_bytes( + include_bytes!("../../shosai-core/tests/fixtures/sample.cbz").to_vec(), + ) + .expect("fixture should be a valid CBZ"); + let mut state = state_with_document(OpenDocument::Cbz(Arc::new(cbz))); + let old_document = match state.document.as_ref() { + Some(OpenDocument::Cbz(document)) => Arc::clone(document), + _ => panic!("expected active CBZ"), + }; + state.document_open_generation = 2; + state.document_opening = true; + let epub = EpubDoc::from_bytes( + include_bytes!("../../shosai-core/tests/fixtures/sample.epub").to_vec(), + ) + .expect("fixture should be a valid EPUB"); + + let task = update( + &mut state, + Message::DocumentOpened { + generation: 1, + path: PathBuf::from("stale.epub"), + book_id: None, + result: Ok(OpenDocument::Epub(Arc::new(epub))), + }, + ); + + assert_eq!(task.units(), 0); + assert!(state.document_opening); + let Some(OpenDocument::Cbz(document)) = &state.document else { + panic!("stale open replaced the active document"); + }; + assert!(Arc::ptr_eq(document, &old_document)); + } + #[test] fn corrupt_and_oversized_epubs_report_actionable_open_errors() { let directory = tempfile::tempdir().unwrap(); @@ -9109,7 +9906,7 @@ mod tests { let rejected = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../shosai-core/tests/fixtures/epub-conformance/resource-limits.epub"); - let task = open_document(&mut state, rejected, None); + let task = open_document_now(&mut state, rejected, None); assert_eq!(task.units(), 0); assert_eq!(state.render_generation, old_generation); @@ -9143,6 +9940,161 @@ mod tests { assert!(state.error.is_none()); } + #[tokio::test] + async fn epub_refresh_paginates_the_current_chapter_before_the_whole_document() { + use iced::futures::StreamExt; + + let epub = EpubDoc::from_bytes( + include_bytes!("../../shosai-core/tests/fixtures/sample.epub").to_vec(), + ) + .expect("fixture should be a valid EPUB"); + let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); + state.current_page = 1; + + let task = refresh_content(&mut state); + let mut messages = iced_runtime::task::into_stream(task).expect("pagination task"); + let mut pagination = Vec::new(); + while pagination.len() < 2 { + let iced_runtime::Action::Output(message) = + messages.next().await.expect("pagination message") + else { + continue; + }; + if matches!(message, Message::EpubPaginated { .. }) { + pagination.push(message); + } + } + let complete = pagination.pop().unwrap(); + let initial = pagination.pop().unwrap(); + + let Message::EpubPaginated { + complete: false, + pages: initial_pages, + .. + } = initial + else { + panic!("first message should contain initial EPUB pages"); + }; + assert!(!initial_pages.is_empty()); + assert!(initial_pages.iter().all(|page| page.chapter == 1)); + + let Message::EpubPaginated { + complete: true, + pages: complete_pages, + .. + } = complete + else { + panic!("second message should contain the complete EPUB layout"); + }; + assert!(complete_pages.iter().any(|page| page.chapter == 0)); + assert!(complete_pages.iter().any(|page| page.chapter == 1)); + } + + #[test] + fn epub_image_loading_is_off_thread_and_bounded_to_nearby_chapters() { + let epub = EpubDoc::from_bytes(epub_with_image_chapters(5)).unwrap(); + let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); + state.current_page = 2; + + let task = load_epub_images_task(&mut state); + + assert_eq!(task.units(), 1); + assert!(state.epub_image_handles.is_empty()); + assert_eq!(state.epub_images_pending.len(), 3); + for chapter in [1, 2, 3] { + assert!( + state + .epub_images_pending + .iter() + .any(|path| path.ends_with(&format!("image-{chapter}.png"))) + ); + } + assert!( + state + .epub_images_pending + .iter() + .all(|path| !path.ends_with("image-0.png") && !path.ends_with("image-4.png")) + ); + } + + #[test] + fn epub_image_completion_is_scoped_to_the_document_not_relayout() { + let epub = EpubDoc::from_bytes(epub_with_image_chapters(1)).unwrap(); + let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); + let _ = load_epub_images_task(&mut state); + let path = state.epub_images_pending.iter().next().unwrap().clone(); + let generation = state.epub_image_generation; + state.render_generation = state.render_generation.wrapping_add(1); + + let _ = update( + &mut state, + Message::EpubImagesDecoded { + tab_id: 1, + generation, + images: vec![( + path.clone(), + Some(DecodedEpubImage::Raster { + width: 1, + height: 1, + pixels: vec![1, 2, 3, 255], + }), + )], + }, + ); + + assert!(!state.epub_images_pending.contains(&path)); + assert!(state.epub_image_handles.contains_key(&path)); + } + + #[test] + fn epub_image_scrubbing_keeps_one_batch_and_prioritizes_the_latest_chapter() { + let epub = EpubDoc::from_bytes(epub_with_image_chapters(5)).unwrap(); + let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); + let _ = load_epub_images_task(&mut state); + let obsolete = state + .epub_images_pending + .iter() + .cloned() + .collect::>(); + + state.current_page = 4; + assert_eq!(load_epub_images_task(&mut state).units(), 0); + assert!(state.epub_image_decode_active); + assert_eq!(state.epub_images_desired.len(), 2); + + let next = update( + &mut state, + Message::EpubImagesDecoded { + tab_id: 1, + generation: 0, + images: obsolete + .into_iter() + .map(|path| { + ( + path, + Some(DecodedEpubImage::Raster { + width: 1, + height: 1, + pixels: vec![1, 2, 3, 255], + }), + ) + }) + .collect(), + }, + ); + + assert_eq!(next.units(), 1); + assert!(state.epub_image_handles.is_empty()); + assert!(state.epub_image_decode_active); + assert_eq!(state.epub_images_pending.len(), 2); + assert!( + state + .epub_images_pending + .iter() + .all(|path| { path.ends_with("image-3.png") || path.ends_with("image-4.png") }) + ); + } + #[test] fn reader_tabs_share_paginated_layout_storage() { let epub = EpubDoc::from_bytes( @@ -9409,10 +10361,6 @@ mod tests { }, ]); state.epub_layout_key = Some(epub_layout_key(&state)); - state.epub_image_handles.insert( - "cached.png".to_string(), - EpubImageHandle::Raster(image::Handle::from_rgba(1, 1, vec![0, 0, 0, 0])), - ); state.bookmarks.push(Bookmark { id: 1, file_path: "book.epub".to_string(), @@ -9430,7 +10378,6 @@ mod tests { let pages = Arc::clone(&state.epub_pages); let layout_key = state.epub_layout_key; let render_generation = state.render_generation; - let cached_image = state.epub_image_handles["cached.png"].raster_id(); let presentation = match &state.document { Some(OpenDocument::Epub(document)) => document.presentation() as *const _, _ => panic!("expected EPUB document"), @@ -9438,14 +10385,10 @@ mod tests { let task = turn_epub_page(&mut state, true); - assert_eq!(task.units(), 0); + assert!(task.units() > 0); assert!(Arc::ptr_eq(&state.epub_pages, &pages)); assert_eq!(state.epub_layout_key, layout_key); assert_eq!(state.render_generation, render_generation); - assert_eq!( - state.epub_image_handles["cached.png"].raster_id(), - cached_image - ); let current_presentation = match &state.document { Some(OpenDocument::Epub(document)) => document.presentation() as *const _, _ => panic!("expected EPUB document"), @@ -9491,17 +10434,12 @@ mod tests { }, ]); state.epub_layout_key = Some(epub_layout_key(&state)); - state.epub_image_handles.insert( - "cached.png".to_string(), - EpubImageHandle::Raster(image::Handle::from_rgba(1, 1, vec![0, 0, 0, 0])), - ); let pages = Arc::clone(&state.epub_pages); let layout_key = state.epub_layout_key; let render_generation = state.render_generation; let search_document_generation = state.search_document_generation; let search_query_generation = state.search_query_generation; - let cached_image = state.epub_image_handles["cached.png"].raster_id(); let (presentation, fonts, native_text_id) = match &state.document { Some(OpenDocument::Epub(document)) => ( document.presentation() as *const _, @@ -9513,7 +10451,7 @@ mod tests { for turn in 0..64 { let forward = turn % 2 == 0; - assert_eq!(turn_epub_page(&mut state, forward).units(), 0); + let _ = turn_epub_page(&mut state, forward); assert_eq!(state.current_page, usize::from(forward)); assert!(matches!( queued_saves.try_recv(), @@ -9527,11 +10465,6 @@ mod tests { assert_eq!(state.render_generation, render_generation); assert_eq!(state.search_document_generation, search_document_generation); assert_eq!(state.search_query_generation, search_query_generation); - assert_eq!(state.epub_image_handles.len(), 1); - assert_eq!( - state.epub_image_handles["cached.png"].raster_id(), - cached_image - ); let (current_presentation, current_fonts, current_native_text_id) = match &state.document { Some(OpenDocument::Epub(document)) => ( document.presentation() as *const _, @@ -9609,9 +10542,17 @@ mod tests { async fn reading_state_writer_coalesces_queued_positions() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("book.epub"); + std::fs::copy( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../shosai-core/tests/fixtures/sample.epub"), + &path, + ) + .unwrap(); let store = ReadingStateStore::open_at_async(&directory.path().join("state.db")) .await .unwrap(); + let library = Library::new(store.pool().clone(), store.managed_books_dir()); + let book = library.import_file(&path).await.unwrap(); let saves = start_reading_state_writer(store.clone()); for page in 1..=3 { @@ -9627,6 +10568,14 @@ mod tests { })) .unwrap(); } + for progress in [0.25, 0.5, 0.75] { + saves + .send(ReadingStateWriterMessage::Progress { + book_id: book.id, + progress, + }) + .unwrap(); + } let (flushed, wait_for_flush) = oneshot::channel(); saves @@ -9640,6 +10589,7 @@ mod tests { .expect("flush should persist the latest queued position"); assert_eq!(saved.page, 3); assert_eq!(saved.location_offset, Some(30)); + assert_eq!(library.get(book.id).await.unwrap().unwrap().progress, 0.75); } #[tokio::test] @@ -9766,6 +10716,7 @@ mod tests { include_bytes!("../../shosai-core/tests/fixtures/sample.cbz").to_vec(), ) .expect("fixture should be a valid CBZ"); + cbz.page_size(0).unwrap(); let mut state = state_with_document(OpenDocument::Cbz(Arc::new(cbz))); let key = PageCacheKey { page: 0, @@ -9841,6 +10792,46 @@ mod tests { assert_eq!(state.search_current, 0); } + #[test] + fn document_search_runs_only_after_the_latest_debounce() { + let epub = EpubDoc::from_bytes( + include_bytes!("../../shosai-core/tests/fixtures/sample.epub").to_vec(), + ) + .expect("fixture should be a valid EPUB"); + let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); + + let debounce = update( + &mut state, + Message::SearchQueryChanged("sample".to_string()), + ); + let query_generation = state.search_query_generation; + let document_generation = state.search_document_generation; + + assert!(debounce.units() > 0); + assert_eq!( + update( + &mut state, + Message::SearchQueryDebounced { + tab_id: 1, + document_generation, + query_generation: query_generation - 1, + }, + ) + .units(), + 0 + ); + + let search = update( + &mut state, + Message::SearchQueryDebounced { + tab_id: 1, + document_generation, + query_generation, + }, + ); + assert!(search.units() > 0); + } + #[test] fn rendered_node_lengths_match_search_text_offsets() { let nodes = vec![ContentNode::BlockQuote { @@ -9956,9 +10947,17 @@ mod tests { state.bookmark_store = Some(BookmarkStore::new(store.pool().clone())); state.i18n.set_preference(LanguagePreference::Japanese); - tokio::task::block_in_place(|| { - let _ = update(&mut state, Message::ToggleBookmark); - }); + use iced::futures::StreamExt; + + let task = update(&mut state, Message::ToggleBookmark); + assert!(state.bookmarks.is_empty()); + let mut messages = iced_runtime::task::into_stream(task).expect("bookmark task"); + let iced_runtime::Action::Output(message) = + messages.next().await.expect("bookmark completion") + else { + panic!("bookmark task should produce a message"); + }; + let _ = update(&mut state, message); assert_eq!(state.bookmarks.len(), 1); assert!(state.bookmarks[0].title.is_none()); diff --git a/crates/shosai-app/src/app/dispatch.rs b/crates/shosai-app/src/app/dispatch.rs index e88cdd36..ef2223a5 100644 --- a/crates/shosai-app/src/app/dispatch.rs +++ b/crates/shosai-app/src/app/dispatch.rs @@ -181,6 +181,16 @@ pub fn update(state: &mut State, message: Message) -> Task { } = initialized; state.i18n.set_preference(language_preference); let pool = store.pool().clone(); + let backfill_store = store.clone(); + let backfill_task = Task::perform( + async move { + backfill_store + .backfill_missing_fingerprints() + .await + .map_err(|error| format!("{error:#}")) + }, + Message::FingerprintBackfillFinished, + ); state.library = Some(Library::new(pool.clone(), managed_books_dir)); state.bookmark_store = Some(BookmarkStore::new(pool)); state.reading_state_saves = Some(start_reading_state_writer(store.clone())); @@ -203,10 +213,11 @@ pub fn update(state: &mut State, message: Message) -> Task { if let Some(pending) = state.pending_open.take() { return Task::batch([ geometry_task, + backfill_task, Task::done(Message::FileSelected(Some(pending))), ]); } - return Task::batch([geometry_task, reset_library(state)]); + return Task::batch([geometry_task, backfill_task, reset_library(state)]); } Message::Initialized(Err(error)) => { @@ -219,6 +230,12 @@ pub fn update(state: &mut State, message: Message) -> Task { } } + Message::FingerprintBackfillFinished(Err(error)) => { + eprintln!("warning: failed to backfill legacy book fingerprints: {error}"); + } + + Message::FingerprintBackfillFinished(Ok(())) => {} + Message::OpenFile => { let ebooks = state.i18n.text("ebooks"); let open_file = state.i18n.text("open-file"); @@ -249,6 +266,38 @@ pub fn update(state: &mut State, message: Message) -> Task { Message::FileSelected(None) => {} + Message::DocumentOpened { + generation, + path, + book_id, + result, + } => { + if generation != state.document_open_generation { + return Task::none(); + } + state.document_opening = false; + state.document_open_notice_visible = false; + state.document_open_preview = None; + match result { + Ok(document) => return finish_open_document(state, path, book_id, document), + Err(error) => { + let performance_task = perf::fail(state, &error.diagnostic()); + state.screen = Screen::Reader; + state.open_error = Some(error); + return performance_task; + } + } + } + + Message::ShowDocumentOpenNotice(generation) + if generation == state.document_open_generation && state.document_opening => + { + state.document_open_notice_visible = true; + state.screen = Screen::Reader; + } + + Message::ShowDocumentOpenNotice(_) => {} + Message::NextPage => { if uses_paginated_epub_layout(state) { return turn_epub_page(state, true); @@ -375,8 +424,9 @@ pub fn update(state: &mut State, message: Message) -> Task { && state.continuous_activation == activation { let is_epub = matches!(state.document, Some(OpenDocument::Epub(_))); + let index = continuous_measurement_index(state, tab_id, activation); return iced::advanced::widget::operate(ContinuousItemOperation::resolve( - continuous_measured_items(state, tab_id, activation), + index, continuous_scroll_id(tab_id, activation), offset, )) @@ -410,7 +460,10 @@ pub fn update(state: &mut State, message: Message) -> Task { state.page_input = (page + 1).to_string(); save_reading_state(state); update_bookmark_status(state); - return reconcile_continuous_rasters(state); + return Task::batch([ + reconcile_continuous_rasters(state), + load_epub_images_task(state), + ]); } } @@ -588,37 +641,43 @@ pub fn update(state: &mut State, message: Message) -> Task { } } - Message::LibraryIndexLoaded { generation, ids } => { - if generation != state.library_generation { - return Task::none(); - } - state.library_book_ids = Arc::new(ids); - state.library_offset = 0; - state.library_has_more = !state.library_book_ids.is_empty(); - if state.library_has_more { - return load_library_page(state, false); - } - state.library_books.clear(); - state.library_loading = false; - } - Message::LibraryLoaded { generation, offset, next_offset, - page, + mut page, } => { if generation != state.library_generation || offset != state.library_offset { return Task::none(); } + let covers = page + .books + .iter_mut() + .filter_map(|book| book.cover.take().map(|cover| (book.id, cover))) + .collect(); if offset > 0 { state.library_books.extend(page.books); } else { state.library_books = page.books; + state.library_cover_handles.clear(); } state.library_offset = next_offset; - state.library_has_more = next_offset < state.library_book_ids.len(); + state.library_has_more = page.has_more; state.library_loading = false; + if offset == 0 { + state.library_activity_progress = 1.0; + } + return decode_library_covers_task(generation, offset, covers); + } + + Message::LibraryCoversLoaded { + generation, + offset, + cover_handles, + } => { + if generation == state.library_generation && offset < state.library_offset { + state.library_cover_handles.extend(cover_handles); + } } Message::OpenAddBooks => { @@ -917,8 +976,8 @@ pub fn update(state: &mut State, message: Message) -> Task { state.book_menu = None; state.pending_remove_book = None; let path = PathBuf::from(file_path); - state.screen = Screen::Reader; if !path.exists() { + state.screen = Screen::Reader; state.open_error = Some(AppError::MissingBook); state.missing_book_id = Some(book_id); return Task::none(); @@ -1022,6 +1081,7 @@ pub fn update(state: &mut State, message: Message) -> Task { match result { Ok(()) => { state.library_books.retain(|book| book.id != id); + state.library_cover_handles.remove(&id); let mut detached_paths = BTreeSet::new(); let mut detached_saves = Vec::new(); if state.book_id == Some(id) { @@ -1093,7 +1153,29 @@ pub fn update(state: &mut State, message: Message) -> Task { Message::LibrarySearchChanged(query) => { state.library_search = query; - return reset_library(state); + state.library_generation = state.library_generation.wrapping_add(1); + let generation = state.library_generation; + state.library_loading = false; + return Task::perform( + async move { + tokio::time::sleep(SEARCH_DEBOUNCE).await; + generation + }, + Message::LibrarySearchDebounced, + ); + } + + Message::LibrarySearchDebounced(generation) => { + if generation != state.library_generation { + return Task::none(); + } + state.library_offset = 0; + state.library_has_more = false; + state.book_menu = None; + state.pending_remove_book = None; + state.library_error = None; + state.library_activity_progress = 0.0; + return load_library_page(state, false); } Message::LibraryFilterChanged(filter) => { @@ -1335,30 +1417,74 @@ pub fn update(state: &mut State, message: Message) -> Task { // Bookmarks Message::ToggleBookmark => { - if let (Some(path), Some(store)) = (&state.file_path, &state.bookmark_store) { + if let (Some(tab_id), Some(path), Some(store)) = + (state.active_tab_id, &state.file_path, &state.bookmark_store) + { + let path = path.clone(); + let task_path = path.clone(); + let store = store.clone(); + let book_id = state.book_id; let page = state.current_page; let location_offset = current_epub_offset(state); - let result = if let Some(book_id) = state.book_id { - store.toggle_for_book_at(book_id, path, page, location_offset, None) - } else { - store.toggle_at(path, page, location_offset, None) - }; - match result { - Ok(Some(bookmark)) => { - state.bookmarks.push(bookmark); - state.current_page_bookmarked = true; - } - Ok(None) => { - state.bookmarks.retain(|bookmark| { - bookmark.page != page - || bookmark.location_offset != location_offset - || bookmark.note.is_some() - }); - state.current_page_bookmarked = false; - } - Err(e) => eprintln!("warning: failed to toggle bookmark: {e}"), + return Task::perform( + async move { + let result = if let Some(book_id) = book_id { + store + .toggle_for_book_at_async( + book_id, + &task_path, + page, + location_offset, + None, + ) + .await + } else { + store + .toggle_at_async(&task_path, page, location_offset, None) + .await + }; + result.map_err(|error| format!("{error:#}")) + }, + move |result| Message::BookmarkToggled { + tab_id, + file_path: path, + book_id, + page, + location_offset, + result, + }, + ); + } + } + + Message::BookmarkToggled { + tab_id, + file_path, + book_id, + page, + location_offset, + result, + } => { + if state.active_tab_id != Some(tab_id) + || state.file_path.as_ref() != Some(&file_path) + || state.book_id != book_id + { + return Task::none(); + } + match result { + Ok(Some(bookmark)) => { + state.bookmarks.push(bookmark); + state.current_page_bookmarked = true; } - return refresh_bookmarks(state); + Ok(None) => { + state.bookmarks.retain(|bookmark| { + bookmark.page != page + || bookmark.location_offset != location_offset + || bookmark.note.is_some() + }); + state.current_page_bookmarked = false; + } + Err(error) => eprintln!("warning: failed to toggle bookmark: {error}"), } } @@ -1403,20 +1529,39 @@ pub fn update(state: &mut State, message: Message) -> Task { } Message::SaveNote => { - if let (Some(id), Some(store)) = (state.editing_note_id, &state.bookmark_store) { + if let (Some(tab_id), Some(path), Some(id), Some(store)) = ( + state.active_tab_id, + &state.file_path, + state.editing_note_id, + &state.bookmark_store, + ) { let note = if state.editing_note_text.is_empty() { None } else { - Some(state.editing_note_text.as_str()) + Some(state.editing_note_text.clone()) }; - let rt = tokio::runtime::Handle::current(); - if let Err(e) = rt.block_on(store.update_note_async(id, note)) { - eprintln!("warning: failed to save note: {e}"); - } + let store = store.clone(); + let file_path = path.clone(); + let book_id = state.book_id; + state.editing_note_id = None; + state.editing_note_text = String::new(); + return Task::perform( + async move { + store + .update_note_async(id, note.as_deref()) + .await + .map_err(|error| format!("{error:#}")) + }, + move |result| Message::BookmarkMutationFinished { + tab_id, + file_path, + book_id, + result, + }, + ); } state.editing_note_id = None; state.editing_note_text = String::new(); - return refresh_bookmarks(state); } Message::CancelEditNote => { @@ -1425,13 +1570,45 @@ pub fn update(state: &mut State, message: Message) -> Task { } Message::DeleteBookmark(id) => { - if let Some(store) = &state.bookmark_store { - let rt = tokio::runtime::Handle::current(); - if let Err(e) = rt.block_on(store.remove_async(id)) { - eprintln!("warning: failed to delete bookmark: {e}"); - } + if let (Some(tab_id), Some(path), Some(store)) = + (state.active_tab_id, &state.file_path, &state.bookmark_store) + { + let store = store.clone(); + let file_path = path.clone(); + let book_id = state.book_id; + return Task::perform( + async move { + store + .remove_async(id) + .await + .map_err(|error| format!("{error:#}")) + }, + move |result| Message::BookmarkMutationFinished { + tab_id, + file_path, + book_id, + result, + }, + ); + } + } + + Message::BookmarkMutationFinished { + tab_id, + file_path, + book_id, + result, + } => { + if let Err(error) = result { + eprintln!("warning: failed to update bookmark: {error}"); + return Task::none(); + } + if state.active_tab_id == Some(tab_id) + && state.file_path.as_ref() == Some(&file_path) + && state.book_id == book_id + { + return refresh_bookmarks(state); } - return refresh_bookmarks(state); } Message::ExportBookmarks => { @@ -1492,11 +1669,43 @@ pub fn update(state: &mut State, message: Message) -> Task { state.search_current = 0; let render_task = refresh_pdf_search_highlights_if_changed(state, &previous_highlights); if !state.search_query.is_empty() { - return Task::batch([render_task, perform_search(state)]); + let Some(tab_id) = state.active_tab_id else { + return render_task; + }; + let document_generation = state.search_document_generation; + let query_generation = state.search_query_generation; + let debounce = Task::perform( + async move { + tokio::time::sleep(SEARCH_DEBOUNCE).await; + (tab_id, document_generation, query_generation) + }, + |(tab_id, document_generation, query_generation)| { + Message::SearchQueryDebounced { + tab_id, + document_generation, + query_generation, + } + }, + ); + return Task::batch([render_task, debounce]); } return render_task; } + Message::SearchQueryDebounced { + tab_id, + document_generation, + query_generation, + } => { + if state.active_tab_id == Some(tab_id) + && state.search_document_generation == document_generation + && state.search_query_generation == query_generation + && !state.search_query.is_empty() + { + return perform_search(state); + } + } + Message::SearchTextExtracted { tab_id, document_generation, @@ -1572,12 +1781,16 @@ pub fn update(state: &mut State, message: Message) -> Task { tab_id, generation, layout_key, + complete, pages, } => { if state.active_tab_id == Some(tab_id) && generation == state.render_generation && layout_key == epub_layout_key(state) { + if !complete && state.epub_layout_key == Some(layout_key) { + return Task::none(); + } state.epub_pages = pages; state.epub_layout_key = Some(layout_key); if state.epub_pages.is_empty() { @@ -1596,6 +1809,9 @@ pub fn update(state: &mut State, message: Message) -> Task { && layout_key == epub_layout_key_for_tab(state, &state.tabs[index]); if accepts_layout { let tab = &mut state.tabs[index]; + if !complete && tab.epub_layout_key == Some(layout_key) { + return Task::none(); + } tab.epub_pages = pages; tab.epub_layout_key = Some(layout_key); if tab.epub_pages.is_empty() { @@ -1612,6 +1828,54 @@ pub fn update(state: &mut State, message: Message) -> Task { } } + Message::EpubImagesDecoded { + tab_id, + generation, + images, + } => { + if state.active_tab_id == Some(tab_id) && generation == state.epub_image_generation { + state.epub_image_decode_active = false; + for (path, decoded) in images { + state.epub_images_pending.remove(&path); + if state.epub_images_desired.remove(&path) { + if let Some(decoded) = decoded { + state.epub_image_handles.insert(path, decoded.into_handle()); + } else { + state.epub_images_failed.insert(path); + } + } + } + return load_epub_images_task(state); + } else if let Some(tab) = state.tabs.iter_mut().find(|tab| tab.id == tab_id) + && generation == tab.epub_image_generation + { + tab.epub_image_decode_active = false; + for (path, decoded) in images { + tab.epub_images_pending.remove(&path); + if tab.epub_images_desired.remove(&path) { + if let Some(decoded) = decoded { + tab.epub_image_handles.insert(path, decoded.into_handle()); + } else { + tab.epub_images_failed.insert(path); + } + } + } + } + } + + Message::CbzDimensionsLoaded { + tab_id, + generation, + result, + } => { + if state.active_tab_id == Some(tab_id) && generation == state.render_generation { + match result { + Ok(()) => return refresh_content(state), + Err(error) => state.error = Some(AppError::Render(error)), + } + } + } + Message::PageRendered { tab_id, generation, diff --git a/crates/shosai-app/src/app/epub_navigation.rs b/crates/shosai-app/src/app/epub_navigation.rs index 63752281..6e4f07d5 100644 --- a/crates/shosai-app/src/app/epub_navigation.rs +++ b/crates/shosai-app/src/app/epub_navigation.rs @@ -179,7 +179,7 @@ pub(super) fn turn_epub_page(state: &mut State, forward: bool) -> Task state.epub_page = page; sync_epub_location(state); save_reading_state(state); - Task::none() + super::load_epub_images_task(state) } pub(super) fn can_turn_epub_page(state: &State, forward: bool) -> bool { diff --git a/crates/shosai-app/src/app/epub_view.rs b/crates/shosai-app/src/app/epub_view.rs index da0a2f07..c1d8d2b7 100644 --- a/crates/shosai-app/src/app/epub_view.rs +++ b/crates/shosai-app/src/app/epub_view.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use iced::widget::{column, container, image, rich_text, row, scrollable, sensor, span, svg, text}; use iced::{Element, Font, Length}; @@ -9,11 +9,12 @@ use shosai_core::epub::{ }; use super::{ - BOOKMARKS_PANEL_WIDTH, EPUB_BLOCKQUOTE_SPACING, EPUB_PAGE_NUMBER_SIZE, EPUB_TABLE_CELL_PADDING, - EPUB_TABLE_CELL_SPACING, EPUB_TABLE_ROW_SPACING, EpubImageHandle, Message, OpenDocument, - PAGE_GUTTER, SearchHighlight, State, continuous_epub_node_id, continuous_epub_title_id, - continuous_item_id, continuous_scroll_id, epub_page_size, epub_uses_spread, epub_visible_pages, - search_highlight_models_for_page, uses_compact_reader_layout, + BOOKMARKS_PANEL_WIDTH, DecodedEpubImage, EPUB_BLOCKQUOTE_SPACING, EPUB_PAGE_NUMBER_SIZE, + EPUB_TABLE_CELL_PADDING, EPUB_TABLE_CELL_SPACING, EPUB_TABLE_ROW_SPACING, EpubImageHandle, + Message, OpenDocument, PAGE_GUTTER, SearchHighlight, State, continuous_epub_node_id, + continuous_epub_title_id, continuous_item_id, continuous_scroll_id, epub_page_size, + epub_uses_spread, epub_visible_pages, search_highlight_models_for_page, + uses_compact_reader_layout, }; use crate::epub::{ content_node_text_len, content_starts_with_heading, spans_font_scale, spans_text_len, @@ -149,43 +150,17 @@ fn continuous_epub_content_width(window_width: f32, show_bookmarks_panel: bool) ((window_width - panel_width).min(800.0) - 40.0).max(120.0) } -pub(super) fn cache_epub_image_handles<'a, F>( - handles: &mut HashMap, +fn collect_epub_image_paths<'a>( + paths: &mut HashSet, nodes: impl IntoIterator, - resource_bytes: &F, -) where - F: Fn(&str) -> Option<&'a [u8]>, -{ +) { for node in nodes { match node { - ContentNode::Image { src, kind, .. } => { - if handles.contains_key(src) { - continue; - } - let Some(data) = resource_bytes(src) else { - continue; - }; - let handle = match kind { - Some(shosai_core::epub::render::ImageKind::Svg) => { - EpubImageHandle::Svg(svg::Handle::from_memory(data.to_vec())) - } - _ => { - let Ok(decoded) = ::image::load_from_memory(data) else { - continue; - }; - let rgba = decoded.to_rgba8(); - let (width, height) = rgba.dimensions(); - EpubImageHandle::Raster(image::Handle::from_rgba( - width, - height, - rgba.into_raw(), - )) - } - }; - handles.insert(src.clone(), handle); + ContentNode::Image { src, .. } => { + paths.insert(src.clone()); } ContentNode::BlockQuote { children, .. } | ContentNode::Figure { children, .. } => { - cache_epub_image_handles(handles, children, resource_bytes); + collect_epub_image_paths(paths, children); } ContentNode::Table { row_groups, .. } => { for cell in row_groups @@ -193,7 +168,7 @@ pub(super) fn cache_epub_image_handles<'a, F>( .flat_map(|group| &group.rows) .flat_map(|row| &row.cells) { - cache_epub_image_handles(handles, &cell.children, resource_bytes); + collect_epub_image_paths(paths, &cell.children); } } _ => {} @@ -201,6 +176,66 @@ pub(super) fn cache_epub_image_handles<'a, F>( } } +pub(super) fn epub_image_paths<'a>( + nodes: impl IntoIterator, +) -> HashSet { + let mut paths = HashSet::new(); + collect_epub_image_paths(&mut paths, nodes); + paths +} + +pub(super) fn decode_epub_images( + document: &EpubDoc, + paths: impl IntoIterator, +) -> Vec<(String, Option)> { + paths + .into_iter() + .map(|path| { + let image = document.resource(&path).and_then(|resource| { + if resource.media_type() == "image/svg+xml" { + return Some(DecodedEpubImage::Svg(resource.bytes().to_vec())); + } + let rgba = ::image::load_from_memory(resource.bytes()).ok()?.to_rgba8(); + let (width, height) = rgba.dimensions(); + Some(DecodedEpubImage::Raster { + width, + height, + pixels: rgba.into_raw(), + }) + }); + (path, image) + }) + .collect() +} + +#[cfg(test)] +pub(super) fn cache_epub_image_handles<'a, F>( + handles: &mut HashMap, + nodes: impl IntoIterator, + resource_bytes: &F, +) where + F: Fn(&str) -> Option<&'a [u8]>, +{ + for path in epub_image_paths(nodes) { + if handles.contains_key(&path) { + continue; + } + let Some(data) = resource_bytes(&path) else { + continue; + }; + let handle = if data.starts_with(b" Element<'_, Message> { let font_size = state.font_size; let text_color = state.theme.text_color(); @@ -251,19 +286,27 @@ pub(super) fn epub_chapter_view(state: &State) -> Element<'_, Message> { ..iced::Padding::ZERO })); } - page = page.push(iced::widget::Space::new().height(Length::Fill)); - page = page.push( - text(format!("{}", page_index + 1)) - .size(EPUB_PAGE_NUMBER_SIZE) - .color(iced::Color { - a: 0.55, - ..text_color - }), - ); - let page_content = container(page) - .width(Length::Fill) - .height(Length::Fill) - .max_width(text_width); + let page_number = text(format!("{}", page_index + 1)) + .size(EPUB_PAGE_NUMBER_SIZE) + .color(iced::Color { + a: 0.55, + ..text_color + }); + let page_content = container( + iced::widget::Stack::new() + .push(container(page).width(Length::Fill).height(Length::Fill)) + .push( + container(page_number) + .width(Length::Fill) + .height(Length::Fill) + .align_bottom(Length::Fill), + ) + .width(Length::Fill) + .height(Length::Fill), + ) + .width(Length::Fill) + .height(Length::Fill) + .max_width(text_width); let content_alignment = if epub_uses_spread(state) { if visible_index == 0 { iced::Alignment::End @@ -522,8 +565,12 @@ fn render_content_node<'a>( available_width, font_size, ); - let column_widths = - crate::epub::epub_table_column_widths(row_groups, table_content_width); + let placements = crate::epub::epub_table_cell_placements(row_groups); + let column_widths = crate::epub::epub_table_column_widths_from_placements( + row_groups, + table_content_width, + &placements, + ); let table_height = available_height.unwrap_or(f32::MAX); let caption_height = crate::epub::epub_table_caption_height( fonts, @@ -537,8 +584,9 @@ fn render_content_node<'a>( * usize::from(!caption.is_empty() && !row_groups.is_empty()) as f32; let table_content_height = (table_height - caption_height - caption_gap).max(1.0); let table_lines_per_page = (table_height / (font_size * 1.2)).max(1.0) as usize; - let geometry = crate::epub::epub_table_geometry_bounded( + let geometry = crate::epub::epub_table_geometry_bounded_from_placements( row_groups, + &placements, &column_widths, table_lines_per_page, font_size, diff --git a/crates/shosai-app/src/app/message.rs b/crates/shosai-app/src/app/message.rs index 5deae43b..3808c22f 100644 --- a/crates/shosai-app/src/app/message.rs +++ b/crates/shosai-app/src/app/message.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -9,15 +10,26 @@ use shosai_core::library::{ }; use shosai_core::search::SearchMatch; -use super::{ContinuousRequest, EpubLayoutKey, EpubPage, InitializedState, PageCacheKey}; +use super::{ + ContinuousRequest, DecodedEpubImage, EpubLayoutKey, EpubPage, InitializedState, PageCacheKey, + RasterImageHandle, +}; #[derive(Debug, Clone)] pub enum Message { Initialized(Result), + FingerprintBackfillFinished(Result<(), String>), // File OpenFile, FileSelected(Option), + DocumentOpened { + generation: u64, + path: PathBuf, + book_id: Option, + result: Result, + }, + ShowDocumentOpenNotice(u64), // Navigation NextPage, @@ -77,16 +89,17 @@ pub enum Message { ShowSettings, RefreshLibrary, LoadMoreLibrary, - LibraryIndexLoaded { - generation: u64, - ids: Vec, - }, LibraryLoaded { generation: u64, offset: usize, next_offset: usize, page: BookPage, }, + LibraryCoversLoaded { + generation: u64, + offset: usize, + cover_handles: HashMap, + }, OpenAddBooks, CancelAddBooks, ChooseBookFiles, @@ -135,6 +148,7 @@ pub enum Message { result: Result<(), String>, }, LibrarySearchChanged(String), + LibrarySearchDebounced(u64), LibraryFilterChanged(Option), LibraryActivityTick, SelectLanguage(crate::i18n::LanguagePreference), @@ -163,6 +177,14 @@ pub enum Message { // Bookmarks ToggleBookmark, + BookmarkToggled { + tab_id: u64, + file_path: PathBuf, + book_id: Option, + page: usize, + location_offset: Option, + result: Result, String>, + }, ToggleBookmarksPanel, BookmarksLoaded { tab_id: u64, @@ -176,11 +198,22 @@ pub enum Message { SaveNote, CancelEditNote, DeleteBookmark(i64), + BookmarkMutationFinished { + tab_id: u64, + file_path: PathBuf, + book_id: Option, + result: Result<(), String>, + }, ExportBookmarks, // In-document search ToggleSearchBar, SearchQueryChanged(String), + SearchQueryDebounced { + tab_id: u64, + document_generation: u64, + query_generation: u64, + }, SearchTextExtracted { tab_id: u64, document_generation: u64, @@ -201,8 +234,19 @@ pub enum Message { tab_id: u64, generation: u64, layout_key: EpubLayoutKey, + complete: bool, pages: Arc>, }, + EpubImagesDecoded { + tab_id: u64, + generation: u64, + images: Vec<(String, Option)>, + }, + CbzDimensionsLoaded { + tab_id: u64, + generation: u64, + result: Result<(), String>, + }, PageRendered { tab_id: u64, generation: u64, diff --git a/crates/shosai-app/src/epub.rs b/crates/shosai-app/src/epub.rs index 2449ec60..009d4c5e 100644 --- a/crates/shosai-app/src/epub.rs +++ b/crates/shosai-app/src/epub.rs @@ -28,6 +28,11 @@ const MAX_EPUB_TABLE_WIDTH: f32 = 4_096.0; const MAX_EPUB_TABLE_COLUMNS: usize = 256; const EPUB_PAGINATION_SHAPE_CHUNK: usize = 4 * 1024; +#[cfg(test)] +thread_local! { + static TABLE_PLACEMENT_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + pub(crate) struct EpubPaginationBudget { remaining_page_breaks: usize, } @@ -1455,7 +1460,9 @@ fn paginate_epub_table( }; let table_width = epub_table_layout_width(row_groups, style, page_width); let content_width = epub_table_content_width(style, table_width, page_width, font_size); - let column_widths = epub_table_column_widths(row_groups, content_width); + let placements = epub_table_cell_placements(row_groups); + let column_widths = + epub_table_column_widths_from_placements(row_groups, content_width, &placements); let caption_height = epub_table_caption_height( fonts, caption, @@ -1466,8 +1473,9 @@ fn paginate_epub_table( ); let caption_gap = EPUB_TABLE_ROW_SPACING * usize::from(!caption.is_empty() && !row_groups.is_empty()) as f32; - let geometry = epub_table_geometry_bounded( + let geometry = epub_table_geometry_bounded_from_placements( row_groups, + &placements, &column_widths, lines_per_page, font_size, @@ -1882,7 +1890,11 @@ pub(crate) fn epub_table_margin_left( } fn epub_table_column_count(row_groups: &[TableRowGroup]) -> usize { - epub_table_cell_placements(row_groups) + epub_table_column_count_from_placements(&epub_table_cell_placements(row_groups)) +} + +fn epub_table_column_count_from_placements(placements: &[Vec]) -> usize { + placements .iter() .flatten() .map(|placement| placement.column + placement.span) @@ -1917,6 +1929,8 @@ pub(crate) struct EpubTableGeometry { pub(crate) fn epub_table_cell_placements( row_groups: &[TableRowGroup], ) -> Vec> { + #[cfg(test)] + TABLE_PLACEMENT_PASSES.with(|passes| passes.set(passes.get() + 1)); let mut placements = Vec::new(); for group in row_groups { let mut occupied_until = vec![0_usize; MAX_EPUB_TABLE_COLUMNS]; @@ -1955,12 +1969,22 @@ pub(crate) fn epub_table_cell_placements( /// Measures the complete logical table once. Pagination and painting provide /// the same intrinsic-cell measurer and consume these row and cell rectangles. +#[cfg(test)] pub(crate) fn epub_table_geometry( row_groups: &[TableRowGroup], column_widths: &[f32], - mut measure_cell: impl FnMut(&shosai_core::epub::render::TableCell, f32) -> f32, + measure_cell: impl FnMut(&shosai_core::epub::render::TableCell, f32) -> f32, ) -> EpubTableGeometry { let placements = epub_table_cell_placements(row_groups); + epub_table_geometry_from_placements(row_groups, &placements, column_widths, measure_cell) +} + +pub(crate) fn epub_table_geometry_from_placements( + row_groups: &[TableRowGroup], + placements: &[Vec], + column_widths: &[f32], + mut measure_cell: impl FnMut(&shosai_core::epub::render::TableCell, f32) -> f32, +) -> EpubTableGeometry { let rows = row_groups .iter() .flat_map(|group| &group.rows) @@ -2054,6 +2078,7 @@ pub(crate) fn epub_table_geometry( } } +#[cfg(test)] pub(crate) fn epub_table_geometry_bounded( row_groups: &[TableRowGroup], column_widths: &[f32], @@ -2062,32 +2087,60 @@ pub(crate) fn epub_table_geometry_bounded( height: f32, fonts: Option<&EpubFontBook>, ) -> EpubTableGeometry { - epub_table_geometry(row_groups, column_widths, |cell, cell_width| { - let chars_per_line = (cell_width / (font_size * AVERAGE_CHARACTER_WIDTH).max(1.0)) - .floor() - .max(1.0) as usize; - let spacing = epub_node_list_spacing(&cell.children, font_size, EPUB_TABLE_CELL_SPACING); - let mut remaining_height = - epub_table_cell_content_height(&cell.children, font_size, height); - let content_height = cell - .children - .iter() - .map(|child| { - let child_height = epub_bounded_node_height( - fonts, - child, - font_size, - cell_width, - remaining_height, - chars_per_line, - lines_per_page, - ); - remaining_height = (remaining_height - child_height).max(1.0); - child_height - }) - .sum::(); - content_height + spacing + 2.0 * EPUB_TABLE_CELL_PADDING - }) + let placements = epub_table_cell_placements(row_groups); + epub_table_geometry_bounded_from_placements( + row_groups, + &placements, + column_widths, + lines_per_page, + font_size, + height, + fonts, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn epub_table_geometry_bounded_from_placements( + row_groups: &[TableRowGroup], + placements: &[Vec], + column_widths: &[f32], + lines_per_page: usize, + font_size: f32, + height: f32, + fonts: Option<&EpubFontBook>, +) -> EpubTableGeometry { + epub_table_geometry_from_placements( + row_groups, + placements, + column_widths, + |cell, cell_width| { + let chars_per_line = (cell_width / (font_size * AVERAGE_CHARACTER_WIDTH).max(1.0)) + .floor() + .max(1.0) as usize; + let spacing = + epub_node_list_spacing(&cell.children, font_size, EPUB_TABLE_CELL_SPACING); + let mut remaining_height = + epub_table_cell_content_height(&cell.children, font_size, height); + let content_height = cell + .children + .iter() + .map(|child| { + let child_height = epub_bounded_node_height( + fonts, + child, + font_size, + cell_width, + remaining_height, + chars_per_line, + lines_per_page, + ); + remaining_height = (remaining_height - child_height).max(1.0); + child_height + }) + .sum::(); + content_height + spacing + 2.0 * EPUB_TABLE_CELL_PADDING + }, + ) } pub(crate) fn epub_table_cell_content_height( @@ -2126,9 +2179,18 @@ pub(crate) fn epub_bounded_node_height( ) } +#[cfg(test)] pub(crate) fn epub_table_column_widths(row_groups: &[TableRowGroup], table_width: f32) -> Vec { let placements = epub_table_cell_placements(row_groups); - let column_count = epub_table_column_count(row_groups); + epub_table_column_widths_from_placements(row_groups, table_width, &placements) +} + +pub(crate) fn epub_table_column_widths_from_placements( + row_groups: &[TableRowGroup], + table_width: f32, + placements: &[Vec], +) -> Vec { + let column_count = epub_table_column_count_from_placements(placements); let gaps = BLOCKQUOTE_SPACING * column_count.saturating_sub(1) as f32; let available = (table_width - gaps).max(column_count as f32); let minimum = (2.0 * EPUB_TABLE_CELL_PADDING + 4.0).min(available / column_count as f32); @@ -2138,7 +2200,7 @@ pub(crate) fn epub_table_column_widths(row_groups: &[TableRowGroup], table_width for (row, row_placements) in row_groups .iter() .flat_map(|group| &group.rows) - .zip(&placements) + .zip(placements) { for (cell, placement) in row.cells.iter().zip(row_placements) { let column = placement.column; @@ -3188,7 +3250,12 @@ fn estimated_epub_compact_node_height_bounded( let table_width = epub_table_layout_width(row_groups, style, width); let table_content_width = epub_table_content_width(style, table_width, width, font_size); - let column_widths = epub_table_column_widths(row_groups, table_content_width); + let placements = epub_table_cell_placements(row_groups); + let column_widths = epub_table_column_widths_from_placements( + row_groups, + table_content_width, + &placements, + ); let caption_height = (!caption.is_empty()).then(|| { epub_table_caption_height( None, @@ -3201,8 +3268,9 @@ fn estimated_epub_compact_node_height_bounded( }); let caption_gap = EPUB_TABLE_ROW_SPACING * usize::from(caption_height.is_some() && !row_groups.is_empty()) as f32; - let geometry = epub_table_geometry_bounded( + let geometry = epub_table_geometry_bounded_from_placements( row_groups, + &placements, &column_widths, lines_per_page, font_size, @@ -5491,6 +5559,33 @@ mod tests { assert_eq!(placements[1][0].column, 1); } + #[test] + fn table_width_and_geometry_chain_builds_placements_once() { + use shosai_core::epub::render::{TableRow, TableRowGroup, TableRowGroupKind}; + + let row_groups = vec![TableRowGroup { + kind: TableRowGroupKind::Body, + rows: vec![TableRow { + cells: vec![table_test_cell("cell", None)], + }], + }]; + TABLE_PLACEMENT_PASSES.with(|passes| passes.set(0)); + + let placements = epub_table_cell_placements(&row_groups); + let widths = epub_table_column_widths_from_placements(&row_groups, 360.0, &placements); + let _geometry = epub_table_geometry_bounded_from_placements( + &row_groups, + &placements, + &widths, + 20, + 16.0, + 600.0, + None, + ); + + TABLE_PLACEMENT_PASSES.with(|passes| assert_eq!(passes.get(), 1)); + } + #[test] fn colspan_and_rowspan_share_placements_and_widths_between_measurement_and_painting() { use shosai_core::epub::render::{TableRow, TableRowGroup, TableRowGroupKind}; diff --git a/crates/shosai-core/migrations/007_library_order_indexes.sql b/crates/shosai-core/migrations/007_library_order_indexes.sql new file mode 100644 index 00000000..4205a4cd --- /dev/null +++ b/crates/shosai-core/migrations/007_library_order_indexes.sql @@ -0,0 +1,5 @@ +CREATE INDEX IF NOT EXISTS books_library_order_idx + ON books(last_read DESC, date_added DESC, id DESC); + +CREATE INDEX IF NOT EXISTS books_format_library_order_idx + ON books(format, last_read DESC, date_added DESC, id DESC); diff --git a/crates/shosai-core/src/cbz.rs b/crates/shosai-core/src/cbz.rs index 7783a95b..9fa8cd3d 100644 --- a/crates/shosai-core/src/cbz.rs +++ b/crates/shosai-core/src/cbz.rs @@ -1,127 +1,264 @@ //! CBZ (Comic Book Zip) format support. //! -//! A CBZ file is a ZIP archive containing image files (JPEG, PNG, GIF, WebP). -//! Pages are determined by sorting image entries in natural filename order. +//! Pages are indexed cheaply and decoded only when requested. use std::io::{Cursor, Read}; use std::path::Path; +use std::sync::Mutex; use anyhow::{Context, Result}; use zip::ZipArchive; use crate::document::{DocumentMetadata, RenderedPage}; -/// Image extensions we recognize as comic pages. const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "bmp"]; +const MIB: u64 = 1024 * 1024; + +/// Resource limits applied while opening and reading a CBZ. +#[derive(Clone, Copy, Debug)] +pub struct CbzLimits { + pub max_archive_bytes: u64, + pub max_entries: usize, + pub max_entry_bytes: u64, + pub max_total_uncompressed_bytes: u64, + pub max_compression_ratio: u64, + pub max_image_width: u32, + pub max_image_height: u32, + pub max_image_pixels: u64, + pub max_decoded_rgba_bytes: u64, +} + +impl Default for CbzLimits { + fn default() -> Self { + Self { + max_archive_bytes: 512 * 1024 * 1024, + max_entries: 10_000, + max_entry_bytes: 128 * 1024 * 1024, + max_total_uncompressed_bytes: 2 * 1024 * 1024 * 1024, + max_compression_ratio: 1_000, + max_image_width: 16_384, + max_image_height: 16_384, + max_image_pixels: 40_000_000, + max_decoded_rgba_bytes: 160 * MIB, + } + } +} -/// A parsed CBZ document. #[derive(Debug)] pub struct CbzDoc { - /// Sorted list of image entry paths within the archive. page_paths: Vec, - /// Raw ZIP data, kept for rendering pages on demand. data: Vec, - /// Title derived from the filename. title: Option, + limits: CbzLimits, + dimensions: Mutex>>, } impl CbzDoc { - /// Open a CBZ file from disk. pub fn open(path: impl AsRef) -> Result { + Self::open_with_limits(path, CbzLimits::default()) + } + + pub fn open_with_limits(path: impl AsRef, limits: CbzLimits) -> Result { let path = path.as_ref(); + let metadata = std::fs::metadata(path) + .with_context(|| format!("failed to inspect {}", path.display()))?; + if metadata.len() > limits.max_archive_bytes { + anyhow::bail!("CBZ archive exceeds encoded byte limit"); + } let data = std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; - let title = path.file_stem().map(|s| s.to_string_lossy().to_string()); - - Self::from_bytes_with_title(data, title) + Self::from_bytes_with_title(data, title, limits) } - /// Open a CBZ from raw bytes. pub fn from_bytes(data: Vec) -> Result { - Self::from_bytes_with_title(data, None) - } - - fn from_bytes_with_title(data: Vec, title: Option) -> Result { - let cursor = Cursor::new(&data); - let mut archive = ZipArchive::new(cursor).context("failed to open CBZ as ZIP archive")?; - - let mut page_paths: Vec = (0..archive.len()) - .filter_map(|i| { - let file = archive.by_index(i).ok()?; - let name = file.name().to_string(); - - // Skip directories and hidden files. - if name.ends_with('/') || name.contains("/__MACOSX") || name.contains("/.") { - return None; - } - - // Check extension. - let ext = name.rsplit('.').next()?.to_lowercase(); - if IMAGE_EXTENSIONS.contains(&ext.as_str()) { - Some(name) - } else { - None - } - }) - .collect(); - - // Natural sort order for correct page numbering (page2 before page10). - page_paths.sort_by(|a, b| natord::compare(a, b)); + Self::from_bytes_with_limits(data, CbzLimits::default()) + } + + pub fn from_bytes_with_limits(data: Vec, limits: CbzLimits) -> Result { + Self::from_bytes_with_title(data, None, limits) + } + fn from_bytes_with_title( + data: Vec, + title: Option, + limits: CbzLimits, + ) -> Result { + if u64::try_from(data.len()).unwrap_or(u64::MAX) > limits.max_archive_bytes { + anyhow::bail!("CBZ archive exceeds encoded byte limit"); + } + let mut archive = + ZipArchive::new(Cursor::new(&data)).context("failed to open CBZ as ZIP archive")?; + if archive.len() > limits.max_entries { + anyhow::bail!("CBZ archive exceeds entry count limit"); + } + + let mut total = 0_u64; + let mut page_paths = Vec::new(); + for i in 0..archive.len() { + let file = archive.by_index(i).context("failed to inspect CBZ entry")?; + let size = file.size(); + if size > limits.max_entry_bytes { + anyhow::bail!("CBZ entry exceeds uncompressed byte limit: {}", file.name()); + } + total = total + .checked_add(size) + .context("CBZ declared size overflow")?; + if total > limits.max_total_uncompressed_bytes { + anyhow::bail!("CBZ archive exceeds aggregate uncompressed byte limit"); + } + let compressed = file.compressed_size(); + if size > 0 + && (compressed == 0 + || size > compressed.saturating_mul(limits.max_compression_ratio)) + { + anyhow::bail!("CBZ entry exceeds compression ratio limit: {}", file.name()); + } + let name = file.name(); + if name.ends_with('/') || name.contains("/__MACOSX") || name.contains("/.") { + continue; + } + if name.rsplit('.').next().is_some_and(|ext| { + IMAGE_EXTENSIONS + .iter() + .any(|known| ext.eq_ignore_ascii_case(known)) + }) { + page_paths.push(name.to_owned()); + } + } + page_paths.sort_by(|a, b| natord::compare(a, b)); if page_paths.is_empty() { anyhow::bail!("CBZ archive contains no image files"); } - + let dimensions = Mutex::new(vec![None; page_paths.len()]); Ok(Self { page_paths, data, title, + limits, + dimensions, }) } - /// Number of pages. pub fn page_count(&self) -> usize { self.page_paths.len() } - /// Render a page by index, decoding the image to RGBA. - /// - /// `scale` multiplies the native image dimensions. - pub fn render_page(&self, index: usize, scale: f32) -> Result { - if index >= self.page_paths.len() { - anyhow::bail!( - "page index {index} out of range (total: {})", - self.page_paths.len() - ); - } - - let path = &self.page_paths[index]; - let cursor = Cursor::new(&self.data); - let mut archive = ZipArchive::new(cursor).context("failed to reopen CBZ archive")?; - + fn image_bytes(&self, index: usize) -> Result> { + let path = self + .page_paths + .get(index) + .context("page index out of range")?; + let mut archive = + ZipArchive::new(Cursor::new(&self.data)).context("failed to reopen CBZ archive")?; let mut file = archive .by_name(path) .with_context(|| format!("image not found in archive: {path}"))?; - - let mut buf = Vec::new(); - file.read_to_end(&mut buf) + let declared = file.size(); + if declared > self.limits.max_entry_bytes + || declared > self.limits.max_total_uncompressed_bytes + { + anyhow::bail!("CBZ entry exceeds streamed byte limit: {path}"); + } + let capacity = usize::try_from(declared.min(self.limits.max_entry_bytes)).unwrap_or(0); + let mut bytes = Vec::with_capacity(capacity); + file.by_ref() + .take(self.limits.max_entry_bytes.saturating_add(1)) + .read_to_end(&mut bytes) .with_context(|| format!("failed to read image: {path}"))?; + let streamed = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if streamed > self.limits.max_entry_bytes + || streamed > self.limits.max_total_uncompressed_bytes + { + anyhow::bail!("CBZ entry exceeds streamed byte limit: {path}"); + } + if streamed != declared { + anyhow::bail!("CBZ entry declared {declared} bytes but streamed {streamed}: {path}"); + } + Ok(bytes) + } - let img = image::load_from_memory(&buf) - .with_context(|| format!("failed to decode image: {path}"))?; + fn dimensions(&self, index: usize) -> Result<(u32, u32)> { + self.page_paths + .get(index) + .context("page index out of range")?; + if let Some(dimensions) = self.cached_dimensions(index) { + return Ok(dimensions); + } + let bytes = self.image_bytes(index)?; + self.inspect_dimensions(index, &bytes) + } + + fn cached_dimensions(&self, index: usize) -> Option<(u32, u32)> { + self.dimensions.lock().expect("dimension cache poisoned")[index] + } + fn inspect_dimensions(&self, index: usize, bytes: &[u8]) -> Result<(u32, u32)> { + let path = self + .page_paths + .get(index) + .context("page index out of range")?; + let reader = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .with_context(|| format!("failed to identify image: {path}"))?; + let (width, height) = reader + .into_dimensions() + .with_context(|| format!("failed to inspect image dimensions: {path}"))?; + self.validate_dimensions(width, height)?; + self.dimensions.lock().expect("dimension cache poisoned")[index] = Some((width, height)); + Ok((width, height)) + } + + fn validate_dimensions(&self, width: u32, height: u32) -> Result<()> { + let pixels = u64::from(width) + .checked_mul(u64::from(height)) + .context("image dimensions overflow")?; + let rgba = pixels + .checked_mul(4) + .context("decoded image size overflow")?; + if width > self.limits.max_image_width + || height > self.limits.max_image_height + || pixels > self.limits.max_image_pixels + || rgba > self.limits.max_decoded_rgba_bytes + { + anyhow::bail!("CBZ image exceeds decoded image limits"); + } + Ok(()) + } + + pub fn render_page(&self, index: usize, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + anyhow::bail!("page scale must be finite and positive"); + } + let path = self + .page_paths + .get(index) + .context("page index out of range")?; + let bytes = self.image_bytes(index)?; + let (width, height) = self + .cached_dimensions(index) + .map(Ok) + .unwrap_or_else(|| self.inspect_dimensions(index, &bytes))?; + let scaled_width = (width as f64 * f64::from(scale)).floor(); + let scaled_height = (height as f64 * f64::from(scale)).floor(); + if scaled_width > f64::from(u32::MAX) || scaled_height > f64::from(u32::MAX) { + anyhow::bail!("scaled image dimensions overflow"); + } + let new_width = scaled_width as u32; + let new_height = scaled_height as u32; + if new_width == 0 || new_height == 0 { + anyhow::bail!("scaled image dimensions must be positive"); + } + self.validate_dimensions(new_width, new_height)?; + let img = image::load_from_memory(&bytes) + .with_context(|| format!("failed to decode image: {path}"))?; let img = if (scale - 1.0).abs() > f32::EPSILON { - let new_w = (img.width() as f32 * scale) as u32; - let new_h = (img.height() as f32 * scale) as u32; - img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) + img.resize(new_width, new_height, image::imageops::FilterType::Lanczos3) } else { img }; - let rgba = img.to_rgba8(); let (width, height) = rgba.dimensions(); - Ok(RenderedPage { width, height, @@ -129,29 +266,25 @@ impl CbzDoc { }) } - /// Get the dimensions of a page without full rendering. pub fn page_size(&self, index: usize) -> Result<(f32, f32)> { - if index >= self.page_paths.len() { - anyhow::bail!( - "page index {index} out of range (total: {})", - self.page_paths.len() - ); - } - - let path = &self.page_paths[index]; - let cursor = Cursor::new(&self.data); - let mut archive = ZipArchive::new(cursor)?; - let mut file = archive.by_name(path)?; - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - - let img = image::load_from_memory(&buf) - .with_context(|| format!("failed to decode image: {path}"))?; + let (width, height) = self.dimensions(index)?; + Ok((width as f32, height as f32)) + } - Ok((img.width() as f32, img.height() as f32)) + /// Return dimensions already discovered by a prior size query or render. + /// + /// Unlike [`Self::page_size`], this never reads or decompresses archive data. + pub fn cached_page_size(&self, index: usize) -> Option<(f32, f32)> { + let (width, height) = self + .dimensions + .lock() + .expect("dimension cache poisoned") + .get(index) + .copied() + .flatten()?; + Some((width as f32, height as f32)) } - /// Get document metadata. pub fn metadata(&self) -> DocumentMetadata { DocumentMetadata { title: self.title.clone(), @@ -161,19 +294,12 @@ impl CbzDoc { } } - /// Get the raw image bytes for a page (for cover extraction). pub fn page_image_bytes(&self, index: usize) -> Result> { - if index >= self.page_paths.len() { - anyhow::bail!("page index {index} out of range"); + let bytes = self.image_bytes(index)?; + if self.cached_dimensions(index).is_none() { + self.inspect_dimensions(index, &bytes)?; } - - let path = &self.page_paths[index]; - let cursor = Cursor::new(&self.data); - let mut archive = ZipArchive::new(cursor)?; - let mut file = archive.by_name(path)?; - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - Ok(buf) + Ok(bytes) } } @@ -181,9 +307,6 @@ impl CbzDoc { mod tests { use super::*; - // Tests use a sample CBZ fixture that must be created by the test harness. - // See tests/cbz_tests.rs for integration tests. - #[test] fn test_image_extensions() { assert!(IMAGE_EXTENSIONS.contains(&"jpg")); diff --git a/crates/shosai-core/src/epub/mod.rs b/crates/shosai-core/src/epub/mod.rs index c9b28985..447b1cb7 100644 --- a/crates/shosai-core/src/epub/mod.rs +++ b/crates/shosai-core/src/epub/mod.rs @@ -29,7 +29,7 @@ pub use native_text::{ EPUB_TEXT_MAX_SCALARS, EpubTextAlign, EpubTextDirection, EpubTextHighlight, EpubTextHit, EpubTextLayout, EpubTextLine, EpubTextRect, EpubTextRequest, EpubTextRun, }; -pub use parser::EpubDoc; +pub use parser::{EpubDoc, EpubInspection}; pub use presentation::{EpubChapterPresentation, EpubPresentation}; pub use resource::{CanonicalEpubPath, EpubReference}; pub use types::*; diff --git a/crates/shosai-core/src/epub/parser.rs b/crates/shosai-core/src/epub/parser.rs index 75b631ed..d34924bf 100644 --- a/crates/shosai-core/src/epub/parser.rs +++ b/crates/shosai-core/src/epub/parser.rs @@ -18,6 +18,11 @@ use crate::document::DocumentMetadata; const MAX_ARCHIVE_ENTRIES: usize = u16::MAX as usize; +#[cfg(test)] +thread_local! { + static PRESENTATION_CONSTRUCTIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A parsed EPUB document. /// /// Raw source content is read-only so it cannot diverge from the cached @@ -36,7 +41,38 @@ pub struct EpubDoc { chapter_index: HashMap, } +/// Metadata and the referenced cover admitted without constructing reading content. +#[derive(Debug)] +pub struct EpubInspection { + metadata: EpubMetadata, + cover: Option>, +} + +impl EpubInspection { + pub fn metadata(&self) -> &EpubMetadata { + &self.metadata + } + + pub fn cover(&self) -> Option<&[u8]> { + self.cover.as_deref() + } +} + impl EpubDoc { + /// Inspect package metadata and its referenced cover without loading reading content. + pub fn inspect(path: impl AsRef) -> Result { + Self::inspect_with_limits(path, EpubLimits::default()) + } + + /// Inspect package metadata and its referenced cover with explicit admission limits. + pub fn inspect_with_limits( + path: impl AsRef, + limits: EpubLimits, + ) -> Result { + let data = read_epub_file(path.as_ref(), &limits)?; + inspect_bytes(data, limits) + } + /// Open an EPUB file from disk. pub fn open(path: impl AsRef) -> Result { Self::open_with_limits(path, EpubLimits::default()) @@ -44,22 +80,7 @@ impl EpubDoc { /// Open an EPUB file with explicit resource admission limits. pub fn open_with_limits(path: impl AsRef, limits: EpubLimits) -> Result { - let path = path.as_ref(); - let file = - File::open(path).with_context(|| format!("failed to read {}", path.display()))?; - let declared_bytes = file - .metadata() - .with_context(|| format!("failed to inspect {}", path.display()))? - .len(); - validate_input_size(declared_bytes, &limits)?; - let capacity = usize::try_from(declared_bytes) - .unwrap_or(usize::MAX) - .min(usize::try_from(limits.max_input_bytes).unwrap_or(usize::MAX)); - let mut data = Vec::with_capacity(capacity); - file.take(limits.max_input_bytes.saturating_add(1)) - .read_to_end(&mut data) - .with_context(|| format!("failed to read {}", path.display()))?; - validate_input_size(data.len() as u64, &limits)?; + let data = read_epub_file(path.as_ref(), &limits)?; Self::from_bytes_with_limits(data, limits) } @@ -84,30 +105,14 @@ impl EpubDoc { include_non_spine_content: bool, limits: EpubLimits, ) -> Result { - validate_input_size(data.len() as u64, &limits)?; - let declared_entries = declared_archive_entry_count(&data, limits.max_archive_entries)?; - let mut validation_archive = ZipArchive::new(Cursor::new(data.as_slice())) - .context("EPUB archive is corrupt: failed to open ZIP archive")?; - validate_archive_entries(&mut validation_archive, declared_entries, &limits, &data)?; - drop(validation_archive); - let cursor = Cursor::new(data); - let mut archive = ZipArchive::new(cursor) - .context("EPUB archive is corrupt: failed to reopen ZIP archive")?; + let mut archive = validated_archive(data, &limits)?; // 1. Parse container.xml to find the OPF path. - let opf_path = parse_container(&mut archive, &limits)?; - - // The OPF directory is used as a base for resolving relative paths. - let opf_dir = opf_path - .rsplit_once('/') - .map_or_else(String::new, |(directory, _)| directory.to_string()); - - // 2. Parse the OPF file. - let opf_xml = read_archive_entry(&mut archive, &opf_path, &limits) - .with_context(|| format!("failed to read OPF file: {opf_path}"))?; - let (metadata, manifest, spine_ids) = parse_opf(&opf_xml, &opf_dir)?; + let (metadata, manifest, spine_ids) = parse_package(&mut archive, &limits)?; validate_spine_size(&spine_ids, &limits)?; + // The OPF directory has already been applied to manifest paths by parse_package. + // 3. Try to parse the TOC (NCX or nav document). let toc = parse_toc(&mut archive, &manifest, &limits)?; @@ -150,6 +155,8 @@ impl EpubDoc { )?; let fonts = super::font::EpubFontBook::new(&chapters, &styles, &resources, &limits)?; + #[cfg(test)] + PRESENTATION_CONSTRUCTIONS.with(|count| count.set(count.get() + 1)); let presentation = EpubPresentation::parse(&chapters, &styles, &fonts, &resources, &limits)?; @@ -278,6 +285,80 @@ impl EpubDoc { // Internal parsing functions // --------------------------------------------------------------------------- +fn read_epub_file(path: &Path, limits: &EpubLimits) -> Result> { + let file = File::open(path).with_context(|| format!("failed to read {}", path.display()))?; + let declared_bytes = file + .metadata() + .with_context(|| format!("failed to inspect {}", path.display()))? + .len(); + validate_input_size(declared_bytes, limits)?; + let capacity = usize::try_from(declared_bytes) + .unwrap_or(usize::MAX) + .min(usize::try_from(limits.max_input_bytes).unwrap_or(usize::MAX)); + let mut data = Vec::with_capacity(capacity); + file.take(limits.max_input_bytes.saturating_add(1)) + .read_to_end(&mut data) + .with_context(|| format!("failed to read {}", path.display()))?; + validate_input_size(data.len() as u64, limits)?; + Ok(data) +} + +fn validated_archive(data: Vec, limits: &EpubLimits) -> Result>>> { + validate_input_size(data.len() as u64, limits)?; + let declared_entries = declared_archive_entry_count(&data, limits.max_archive_entries)?; + let mut validation_archive = ZipArchive::new(Cursor::new(data.as_slice())) + .context("EPUB archive is corrupt: failed to open ZIP archive")?; + validate_archive_entries(&mut validation_archive, declared_entries, limits, &data)?; + drop(validation_archive); + ZipArchive::new(Cursor::new(data)) + .context("EPUB archive is corrupt: failed to reopen ZIP archive") +} + +fn parse_package( + archive: &mut ZipArchive>>, + limits: &EpubLimits, +) -> Result<(EpubMetadata, HashMap, Vec)> { + let opf_path = parse_container(archive, limits)?; + let opf_dir = opf_path + .rsplit_once('/') + .map_or_else(String::new, |(directory, _)| directory.to_string()); + let opf_xml = read_archive_entry(archive, &opf_path, limits) + .with_context(|| format!("failed to read OPF file: {opf_path}"))?; + parse_opf(&opf_xml, &opf_dir) +} + +fn inspect_bytes(data: Vec, limits: EpubLimits) -> Result { + let mut archive = validated_archive(data, &limits)?; + let (metadata, manifest, spine_ids) = parse_package(&mut archive, &limits)?; + validate_spine_size(&spine_ids, &limits)?; + let cover = if let Some(item) = metadata + .cover_image_id + .as_ref() + .and_then(|id| manifest.get(id)) + { + let declared_size = match archive.by_name(&item.href) { + Ok(file) => file.size(), + Err(_) => { + return Ok(EpubInspection { + metadata, + cover: None, + }); + } + }; + validate_declared_resource_size(&item.href, &item.media_type, declared_size, &limits)?; + let data = read_archive_bytes_bounded( + &mut archive, + &item.href, + resource_read_limit(&item.media_type, &limits), + )?; + validate_resource(&item.href, &item.media_type, &data, &limits)?; + Some(data) + } else { + None + }; + Ok(EpubInspection { metadata, cover }) +} + /// Read a file from the ZIP archive as a UTF-8 string. fn read_archive_entry( archive: &mut ZipArchive>>, @@ -978,6 +1059,7 @@ fn resolve_manifest_path(opf_dir: &str, href: &str) -> Result { #[cfg(test)] mod tests { use std::io::{Cursor, Write}; + use std::path::Path; use zip::CompressionMethod; use zip::ZipWriter; @@ -988,6 +1070,7 @@ mod tests { use super::declared_archive_entry_count; use super::parse_opf; use super::validate_spine_size; + use super::{PRESENTATION_CONSTRUCTIONS, inspect_bytes}; fn archive_with_entries(names: &[&str]) -> Vec { let mut archive = ZipWriter::new(Cursor::new(Vec::new())); @@ -1010,6 +1093,52 @@ mod tests { archive.finish().unwrap().into_inner() } + #[test] + fn inspection_matches_full_metadata_and_cover_without_presentation() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sample.epub"); + let full = EpubDoc::open(&path).unwrap(); + PRESENTATION_CONSTRUCTIONS.with(|count| count.set(0)); + + let inspection = EpubDoc::inspect(path).unwrap(); + + assert_eq!(inspection.metadata().title, full.content().metadata.title); + assert_eq!(inspection.metadata().author, full.content().metadata.author); + let expected_cover = full + .content() + .metadata + .cover_image_id + .as_ref() + .and_then(|id| full.content().manifest.get(id)) + .and_then(|item| full.resource(&item.href)) + .map(|resource| resource.bytes()); + assert_eq!(inspection.cover(), expected_cover); + PRESENTATION_CONSTRUCTIONS.with(|count| assert_eq!(count.get(), 0)); + } + + #[test] + fn inspection_retains_archive_and_cover_admission_limits() { + let corrupt = b"not a ZIP archive".to_vec(); + assert!(inspect_bytes(corrupt, EpubLimits::default()).is_err()); + + let bytes = + std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sample.epub")) + .unwrap(); + let limits = EpubLimits { + max_input_bytes: bytes.len() as u64 - 1, + ..EpubLimits::default() + }; + let error = inspect_bytes(bytes, limits).unwrap_err(); + assert!(error.to_string().contains("archive byte limit")); + + let oversized_cover = archive_with_payloads(&[ + ("META-INF/container.xml", br#""#), + ("book.opf", br#""#), + ("cover.png", b"not-an-image"), + ]); + let error = inspect_bytes(oversized_cover, EpubLimits::default()).unwrap_err(); + assert!(error.to_string().contains("could not inspect dimensions")); + } + fn linked_chapter_epub() -> Vec { archive_with_payloads(&[ ("mimetype", b"application/epub+zip"), diff --git a/crates/shosai-core/src/epub/render.rs b/crates/shosai-core/src/epub/render.rs index f7d41d67..525a30c1 100644 --- a/crates/shosai-core/src/epub/render.rs +++ b/crates/shosai-core/src/epub/render.rs @@ -13,6 +13,11 @@ use super::EpubLimits; const MAX_CHAPTER_ANCHORS: usize = 4_096; const MAX_ANCHOR_NAME_BYTES: usize = 1_024; +#[cfg(test)] +thread_local! { + static CAPTION_OFFSET_CHAR_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A styled span of inline text or bounded MathML replacement geometry. #[derive(Debug, Clone, PartialEq)] pub struct TextSpan { @@ -956,6 +961,34 @@ fn parse_block_children( } } + "svg" => { + let mut images = child.descendants().filter(|node| { + node.is_element() + && node.tag_name().name() == "image" + && styles.get(*node).is_some_and(|style| { + style.display != super::computed_style::DisplayRole::None + }) + }); + if let Some(image) = images.next() + && images.next().is_none() + && let Some(href) = image + .attributes() + .find(|attribute| attribute.name() == "href") + .map(|attribute| attribute.value()) + && let Some(src) = resolve_relative(base_path, href) + { + nodes.push(ContentNode::Image { + src, + alt: image.attribute("aria-label").unwrap_or("").to_string(), + style: node_style, + caption: Vec::new(), + caption_style: None, + intrinsic_size: None, + kind: None, + }); + } + } + "hr" => { nodes.push(ContentNode::HorizontalRule); } @@ -1889,6 +1922,7 @@ fn collect_caption_runs( let mut run_spans = Vec::new(); let mut run_anchors = HashMap::new(); let mut raw_offset = 0; + let mut output_offset = 0; for child in caption.children() { let is_block = child.is_element() && styles.get(child).is_some_and(|style| { @@ -1899,7 +1933,13 @@ fn collect_caption_runs( ) }); if is_block { - append_caption_run(spans, anchors, &mut run_spans, &mut run_anchors); + append_caption_run( + spans, + anchors, + &mut output_offset, + &mut run_spans, + &mut run_anchors, + ); raw_offset = 0; } if child.is_text() { @@ -1934,16 +1974,29 @@ fn collect_caption_runs( ); } if is_block { - append_caption_run(spans, anchors, &mut run_spans, &mut run_anchors); + append_caption_run( + spans, + anchors, + &mut output_offset, + &mut run_spans, + &mut run_anchors, + ); raw_offset = 0; } } - append_caption_run(spans, anchors, &mut run_spans, &mut run_anchors); + append_caption_run( + spans, + anchors, + &mut output_offset, + &mut run_spans, + &mut run_anchors, + ); } fn append_caption_run( output: &mut Vec, output_anchors: &mut HashMap, + output_offset: &mut usize, run: &mut Vec, run_anchors: &mut HashMap, ) { @@ -1953,7 +2006,13 @@ fn append_caption_run( run_anchors.clear(); return; } - let mut offset: usize = output.iter().map(|span| span.text.chars().count()).sum(); + let run_len = run + .iter() + .map(|span| span.text.chars().count()) + .sum::(); + #[cfg(test)] + CAPTION_OFFSET_CHAR_VISITS.with(|visits| visits.set(visits.get() + run_len)); + let mut offset = *output_offset; if !output.is_empty() { let mut separator = run[0].clone(); separator.text = "\n".to_owned(); @@ -1965,6 +2024,7 @@ fn append_caption_run( record_anchor_name(&name, offset + anchor_offset, output_anchors); } output.append(run); + *output_offset = offset + run_len; } fn collapse_inline_whitespace(spans: &mut Vec) { @@ -3026,6 +3086,17 @@ mod tests { } } + #[test] + fn svg_wrapped_cover_image_is_preserved() { + let xhtml = r#""#; + let nodes = parse_chapter_xhtml(xhtml, "OEBPS/Text", &Default::default()); + + assert!(matches!( + nodes.as_slice(), + [ContentNode::Image { src, .. }] if src == "OEBPS/Images/cover.jpeg" + )); + } + #[test] fn xhtml_image_dimensions_are_low_specificity_hints() { let xhtml = r#""#; @@ -3151,6 +3222,35 @@ mod tests { ); } + #[test] + fn block_rich_caption_offset_work_is_linear() { + const BLOCK_COUNT: usize = 10_000; + + let mut xhtml = String::from( + r#"
Diagram
"#, + ); + for _ in 0..BLOCK_COUNT { + xhtml.push_str("

x

"); + } + xhtml.push_str("
"); + + CAPTION_OFFSET_CHAR_VISITS.with(|visits| visits.set(0)); + let nodes = parse_chapter_xhtml(&xhtml, "", &Default::default()); + let visits = CAPTION_OFFSET_CHAR_VISITS.with(std::cell::Cell::get); + + let ContentNode::Image { caption, .. } = &nodes[0] else { + panic!("expected semantic image"); + }; + assert_eq!(visits, BLOCK_COUNT); + assert_eq!( + caption + .iter() + .map(|span| span.text.chars().count()) + .sum::(), + BLOCK_COUNT * 2 - 1 + ); + } + #[test] fn figure_preserves_caption_before_image_as_ordered_content() { let xhtml = r#" diff --git a/crates/shosai-core/src/library.rs b/crates/shosai-core/src/library.rs index 22fcbf2b..e3a9852e 100644 --- a/crates/shosai-core/src/library.rs +++ b/crates/shosai-core/src/library.rs @@ -239,7 +239,7 @@ pub struct ManagedPathChange { pub new_path: PathBuf, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] struct FileFingerprint { hash: String, size: u64, @@ -444,17 +444,19 @@ impl Library { let path = canonical_path(path); let path_str = path.to_string_lossy().to_string(); - if let Some(expected_hash) = expected_hash { + let initial_fingerprint = if let Some(expected_hash) = expected_hash { let fingerprint_path = path.clone(); - let actual_hash = tokio::task::spawn_blocking(move || { - file_fingerprint(&fingerprint_path).map(|fingerprint| fingerprint.hash) - }) - .await - .context("book verification task failed")??; - if actual_hash != expected_hash { + let fingerprint = + tokio::task::spawn_blocking(move || file_fingerprint(&fingerprint_path)) + .await + .context("book verification task failed")??; + if fingerprint.hash != expected_hash { bail!("file changed after review: {}", path.display()); } - } + Some(fingerprint) + } else { + None + }; // Check if already imported after validating a reviewed file. if let Some(book) = self.get_by_path(&path_str).await? { @@ -479,6 +481,7 @@ impl Library { &metadata_path, format, expected_hash.as_deref(), + initial_fingerprint, ) }) .await @@ -561,6 +564,7 @@ impl Library { &title_path, format, expected_hash.as_deref(), + None, ) }) .await @@ -1528,15 +1532,39 @@ fn inspect_book( title_path: &Path, format: BookFormat, expected_hash: Option<&str>, + initial_fingerprint: Option, +) -> Result { + inspect_book_with( + path, + title_path, + expected_hash, + initial_fingerprint, + || extract_metadata_and_cover(path, title_path, format), + file_fingerprint, + ) +} + +fn inspect_book_with( + path: &Path, + title_path: &Path, + expected_hash: Option<&str>, + initial_fingerprint: Option, + extract: impl FnOnce() -> Result<(String, Option, Option>)>, + mut fingerprint: impl FnMut(&Path) -> Result, ) -> Result { - if let Some(expected_hash) = expected_hash - && file_fingerprint(path)?.hash != expected_hash + let before = match (initial_fingerprint, expected_hash) { + (Some(fingerprint), _) => Some(fingerprint), + (None, Some(_)) => Some(fingerprint(path)?), + (None, None) => None, + }; + if let (Some(expected_hash), Some(before)) = (expected_hash, &before) + && before.hash != expected_hash { bail!("file changed after review: {}", title_path.display()); } - let (title, author, cover) = extract_metadata_and_cover(path, title_path, format)?; - let fingerprint = file_fingerprint(path)?; - if expected_hash.is_some_and(|expected| expected != fingerprint.hash) { + let (title, author, cover) = extract()?; + let fingerprint = fingerprint(path)?; + if before.as_ref().is_some_and(|before| before != &fingerprint) { bail!("file changed after review: {}", title_path.display()); } Ok(BookInspection { @@ -1943,9 +1971,13 @@ fn extract_pdf_metadata( let title = meta.title.unwrap_or_else(|| filename_title(title_path)); let author = meta.author; - // Render first page as cover thumbnail. + // Render page zero directly at thumbnail size instead of materializing a reader-sized page. + let (page_width, page_height) = doc.page_size(0)?; + let scale = ((COVER_MAX_WIDTH - 1) as f32 / page_width) + .min((COVER_MAX_HEIGHT - 1) as f32 / page_height) + .min(1.0); let cover = doc - .render_page(0, 0.5) // half-scale for thumbnail + .render_page(0, scale) .ok() .and_then(|page| encode_cover_png(page.width, page.height, &page.pixels)); @@ -1956,21 +1988,15 @@ fn extract_epub_metadata( path: &Path, title_path: &Path, ) -> Result<(String, Option, Option>)> { - let doc = EpubDoc::open(path)?; - let meta = &doc.content().metadata; + let inspection = EpubDoc::inspect(path)?; + let meta = inspection.metadata(); let title = meta .title .clone() .unwrap_or_else(|| filename_title(title_path)); let author = meta.author.clone(); - // Extract cover image from manifest. - let cover = meta - .cover_image_id - .as_ref() - .and_then(|id| doc.content().manifest.get(id)) - .and_then(|item| doc.resource(&item.href)) - .and_then(|resource| resize_cover_image(resource.bytes())); + let cover = inspection.cover().and_then(resize_cover_image); Ok((title, author, cover)) } @@ -2057,6 +2083,73 @@ mod tests { assert!(error.to_string().contains("discovery cancelled")); } + #[test] + fn reviewed_inspection_reuses_preinspection_fingerprint_then_hashes_after_extraction() { + use std::cell::RefCell; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("book.epub"); + std::fs::write(&path, b"reviewed bytes").unwrap(); + let before = file_fingerprint(&path).unwrap(); + let expected_hash = before.hash.clone(); + let events = RefCell::new(vec!["pre"]); + + let inspection = inspect_book_with( + &path, + &path, + Some(&expected_hash), + Some(before), + || { + events.borrow_mut().push("inspect"); + Ok(("Book".into(), None, None)) + }, + |path| { + events.borrow_mut().push("post"); + file_fingerprint(path) + }, + ) + .unwrap(); + + assert_eq!(&*events.borrow(), &["pre", "inspect", "post"]); + assert_eq!(inspection.fingerprint.hash, expected_hash); + } + + #[test] + fn reviewed_inspection_rejects_changes_before_or_during_extraction() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("book.epub"); + std::fs::write(&path, b"reviewed bytes").unwrap(); + let reviewed = file_fingerprint(&path).unwrap(); + let expected_hash = reviewed.hash.clone(); + + std::fs::write(&path, b"changed before inspection").unwrap(); + let before_error = inspect_book_with( + &path, + &path, + Some(&expected_hash), + None, + || panic!("changed file must be rejected before extraction"), + file_fingerprint, + ) + .unwrap_err(); + assert!(before_error.to_string().contains("changed after review")); + + std::fs::write(&path, b"reviewed bytes").unwrap(); + let during_error = inspect_book_with( + &path, + &path, + Some(&expected_hash), + Some(reviewed), + || { + std::fs::write(&path, b"replacement during inspection")?; + Ok(("Book".into(), None, None)) + }, + file_fingerprint, + ) + .unwrap_err(); + assert!(during_error.to_string().contains("changed after review")); + } + #[test] fn scanner_streams_candidates_before_directory_enumeration_finishes() { let directory = tempfile::tempdir().unwrap(); @@ -2112,6 +2205,17 @@ mod tests { assert_eq!(snapshot.completed_files, 1); } + #[test] + fn pdf_cover_is_rendered_within_thumbnail_bounds() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sample.pdf"); + + let (_, _, cover) = extract_pdf_metadata(&path, &path).unwrap(); + let cover = image::load_from_memory(&cover.unwrap()).unwrap(); + + assert!(cover.width() <= COVER_MAX_WIDTH); + assert!(cover.height() <= COVER_MAX_HEIGHT); + } + #[test] fn publishing_rejects_a_stage_that_does_not_match_its_expected_hash() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/shosai-core/src/reading_state.rs b/crates/shosai-core/src/reading_state.rs index 6d8895d0..b9c92242 100644 --- a/crates/shosai-core/src/reading_state.rs +++ b/crates/shosai-core/src/reading_state.rs @@ -7,6 +7,7 @@ //! Uses sqlx with SQLite so the same database can be extended for library //! management in future phases. +use std::collections::HashMap; use std::fs::OpenOptions; use std::path::{Path, PathBuf}; @@ -189,8 +190,26 @@ impl ReadingStateStore { Self::open_at_async(&path).await } + /// Open the default store without waiting for legacy book fingerprints to be backfilled. + pub async fn open_async_deferred_backfill() -> Result { + let path = db_file_path()?; + if is_development_profile() { + prepare_development_data_directory(path.parent().context("database has no parent")?)?; + } + Self::open_at_async_deferred_backfill(&path).await + } + /// Async: open at a specific database path. pub async fn open_at_async(db_path: &Path) -> Result { + Self::open_at_inner(db_path, true).await + } + + /// Open a specific store without waiting for legacy book fingerprints. + pub async fn open_at_async_deferred_backfill(db_path: &Path) -> Result { + Self::open_at_inner(db_path, false).await + } + + async fn open_at_inner(db_path: &Path, backfill_fingerprints: bool) -> Result { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create data dir {}", parent.display()))?; @@ -211,11 +230,18 @@ impl ReadingStateStore { db_path: db_path.to_path_buf(), }; store.migrate().await?; - crate::library::backfill_missing_fingerprints(&store.pool).await?; + if backfill_fingerprints { + store.backfill_missing_fingerprints().await?; + } Ok(store) } - /// Run database migrations from the `migrations/` directory. + /// Fill legacy library fingerprints after the store is available to the UI. + pub async fn backfill_missing_fingerprints(&self) -> Result<()> { + crate::library::backfill_missing_fingerprints(&self.pool).await + } + + /// Run schema and query-index migrations from the `migrations/` directory. async fn migrate(&self) -> Result<()> { sqlx::migrate!("./migrations") .run(&self.pool) @@ -345,6 +371,17 @@ impl ReadingStateStore { .map(|row| row.get::("value")) } + /// Get all stored preferences in one query. + pub async fn get_prefs_async(&self) -> HashMap { + sqlx::query("SELECT key, value FROM preferences") + .fetch_all(&self.pool) + .await + .unwrap_or_default() + .into_iter() + .map(|row| (row.get::("key"), row.get::("value"))) + .collect() + } + /// Set a stored preference value. pub async fn set_pref_async(&self, key: &str, value: &str) -> Result<()> { sqlx::query( diff --git a/crates/shosai-core/tests/cbz_tests.rs b/crates/shosai-core/tests/cbz_tests.rs index 99660e54..ba712271 100644 --- a/crates/shosai-core/tests/cbz_tests.rs +++ b/crates/shosai-core/tests/cbz_tests.rs @@ -1,4 +1,4 @@ -use shosai_core::cbz::CbzDoc; +use shosai_core::cbz::{CbzDoc, CbzLimits}; use std::path::PathBuf; fn fixture_path(name: &str) -> PathBuf { @@ -61,9 +61,11 @@ fn test_render_page_scaled() { #[test] fn test_page_size() { let doc = CbzDoc::open(fixture_path("sample.cbz")).unwrap(); + assert_eq!(doc.cached_page_size(0), None); let (w, h) = doc.page_size(0).unwrap(); assert!((w - 100.0).abs() < 1.0); assert!((h - 150.0).abs() < 1.0); + assert_eq!(doc.cached_page_size(0), Some((100.0, 150.0))); } #[test] @@ -106,3 +108,70 @@ fn test_skips_non_image_files() { fn test_open_nonexistent() { assert!(CbzDoc::open("/nonexistent/file.cbz").is_err()); } + +#[test] +fn rejects_oversized_archive_and_entry() { + let data = std::fs::read(fixture_path("sample.cbz")).unwrap(); + let limits = CbzLimits { + max_archive_bytes: data.len() as u64 - 1, + ..CbzLimits::default() + }; + assert!(CbzDoc::from_bytes_with_limits(data.clone(), limits).is_err()); + + let limits = CbzLimits { + max_entry_bytes: 16, + ..CbzLimits::default() + }; + assert!(CbzDoc::from_bytes_with_limits(data, limits).is_err()); +} + +#[test] +fn rejects_oversized_image_before_decode() { + let data = std::fs::read(fixture_path("sample.cbz")).unwrap(); + let limits = CbzLimits { + max_image_pixels: 100, + ..CbzLimits::default() + }; + let doc = CbzDoc::from_bytes_with_limits(data, limits).unwrap(); + assert!( + doc.page_size(0) + .unwrap_err() + .to_string() + .contains("decoded image limits") + ); + assert!(doc.render_page(0, 1.0).is_err()); + assert!(doc.page_image_bytes(0).is_err()); +} + +#[test] +fn rejects_invalid_and_pathologically_small_render_scales() { + let doc = CbzDoc::open(fixture_path("sample.cbz")).unwrap(); + assert!(doc.render_page(0, f32::NAN).is_err()); + assert!(doc.render_page(0, 0.0).is_err()); + assert!(doc.render_page(0, 0.000_001).is_err()); +} + +#[test] +fn rejects_entry_count_and_aggregate_size() { + let data = std::fs::read(fixture_path("sample.cbz")).unwrap(); + let limits = CbzLimits { + max_entries: 1, + ..CbzLimits::default() + }; + assert!(CbzDoc::from_bytes_with_limits(data.clone(), limits).is_err()); + let limits = CbzLimits { + max_total_uncompressed_bytes: 16, + ..CbzLimits::default() + }; + assert!(CbzDoc::from_bytes_with_limits(data, limits).is_err()); +} + +#[test] +fn rejects_excessive_compression_ratio() { + let data = std::fs::read(fixture_path("sample.cbz")).unwrap(); + let limits = CbzLimits { + max_compression_ratio: 0, + ..CbzLimits::default() + }; + assert!(CbzDoc::from_bytes_with_limits(data, limits).is_err()); +} diff --git a/crates/shosai-core/tests/library_tests.rs b/crates/shosai-core/tests/library_tests.rs index c485162c..5cac52cf 100644 --- a/crates/shosai-core/tests/library_tests.rs +++ b/crates/shosai-core/tests/library_tests.rs @@ -124,6 +124,25 @@ async fn test_library_pages_are_bounded_and_combine_filters() { assert_eq!(filtered.books[0].format, BookFormat::Epub); } +#[tokio::test] +async fn library_order_queries_have_covering_sort_indexes() { + let (_lib, store, _dir) = temp_library().await; + let indexes: Vec = sqlx::query_scalar("SELECT name FROM pragma_index_list('books')") + .fetch_all(store.pool()) + .await + .unwrap(); + + assert!( + indexes.iter().any(|name| name == "books_library_order_idx"), + "missing unfiltered library order index: {indexes:?}" + ); + assert!( + indexes + .iter() + .any(|name| name == "books_format_library_order_idx") + ); +} + #[tokio::test] async fn test_library_id_snapshot_stays_stable_when_sort_order_changes() { let (lib, _, _dir) = temp_library().await; diff --git a/crates/shosai-core/tests/reading_state_tests.rs b/crates/shosai-core/tests/reading_state_tests.rs index 7cfd3872..908605bd 100644 --- a/crates/shosai-core/tests/reading_state_tests.rs +++ b/crates/shosai-core/tests/reading_state_tests.rs @@ -259,6 +259,18 @@ async fn test_preferences_persist_across_opens() { } } +#[tokio::test] +async fn all_preferences_are_loaded_together() { + let (store, _dir) = temp_store().await; + store.set_pref_async("first", "one").await.unwrap(); + store.set_pref_async("second", "2").await.unwrap(); + + let preferences = store.get_prefs_async().await; + + assert_eq!(preferences.get("first").map(String::as_str), Some("one")); + assert_eq!(preferences.get("second").map(String::as_str), Some("2")); +} + #[tokio::test] async fn test_multiple_preferences_are_saved_atomically() { let dir = TempDir::new().unwrap(); @@ -306,7 +318,7 @@ async fn test_migrations_are_idempotent() { } #[tokio::test] -async fn migrating_a_v5_database_fingerprints_reachable_legacy_books() { +async fn legacy_fingerprints_can_be_backfilled_after_migration() { let dir = TempDir::new().unwrap(); let db_path = dir.path().join("shosai.db"); let reachable = dir.path().join("reachable.epub"); @@ -335,7 +347,16 @@ async fn migrating_a_v5_database_fingerprints_reachable_legacy_books() { } pool.close().await; - let store = ReadingStateStore::open_at_async(&db_path).await.unwrap(); + let store = ReadingStateStore::open_at_async_deferred_backfill(&db_path) + .await + .unwrap(); + let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM books WHERE content_hash IS NULL") + .fetch_one(store.pool()) + .await + .unwrap(); + assert_eq!(pending, 2); + + store.backfill_missing_fingerprints().await.unwrap(); let rows: Vec<(String, Option, Option)> = sqlx::query_as("SELECT file_path, content_hash, file_size FROM books ORDER BY id") .fetch_all(store.pool()) diff --git a/scripts/check-rfd-status-test.sh b/scripts/check-rfd-status-test.sh index f000642f..0bdb3130 100755 --- a/scripts/check-rfd-status-test.sh +++ b/scripts/check-rfd-status-test.sh @@ -15,9 +15,13 @@ reset_fixtures() { } run_success() { + local expected="${1:-}" if ! NO_COLOR=1 RFD_DIR="${rfd_root}" bash "${checker}" >"${output}" 2>&1; then cat "${output}" >&2; printf 'expected RFD checker to pass\n' >&2; exit 1 fi + if [[ -n "${expected}" ]] && ! grep -Fq "${expected}" "${output}"; then + cat "${output}" >&2; printf 'RFD checker output did not include: %s\n' "${expected}" >&2; exit 1 + fi } run_failure() { @@ -72,8 +76,19 @@ EOF esac } -reset_fixtures; write_valid_rfd discussion https://example.com/pull/1; run_success -reset_fixtures; write_valid_rfd prediscussion "" md; run_success +reset_fixtures; write_valid_rfd discussion https://example.com/pull/1 +cat >>"${rfd_root}/0001/IMPLEMENTATION.org" <<'EOF' +- [X] Finished task. + - [x] Finished nested task. +EOF +run_success "0001 discussion 2/3" + +reset_fixtures; write_valid_rfd prediscussion "" md +cat >>"${rfd_root}/0001/IMPLEMENTATION.md" <<'EOF' +- [x] Finished task. ++ [X] Another finished task. +EOF +run_success "0001 prediscussion 2/3" reset_fixtures; write_valid_rfd prediscussion "" rm "${rfd_root}/0001/IMPLEMENTATION.org" diff --git a/scripts/check-rfd-status.sh b/scripts/check-rfd-status.sh index 8014a50c..8bf73489 100755 --- a/scripts/check-rfd-status.sh +++ b/scripts/check-rfd-status.sh @@ -100,8 +100,8 @@ read_rfd() { ' "${source}" } -printf "%s%-4s %-13s %-35s %s%s\n" "${color_bold}" "RFD" "State" "Title" "Labels" "${color_reset}" -printf "%s%-4s %-13s %-35s %s%s\n" "${color_dim}" "----" "-------------" "-----------------------------------" "--------------------" "${color_reset}" +printf "%s%-4s %-13s %5s %-35s %s%s\n" "${color_bold}" "RFD" "State" "Tasks" "Title" "Labels" "${color_reset}" +printf "%s%-4s %-13s %5s %-35s %s%s\n" "${color_dim}" "----" "-------------" "-----" "-----------------------------------" "--------------------" "${color_reset}" failures=0 found=0 @@ -126,6 +126,7 @@ for entry in "${entries[@]}"; do number="$(printf '%s\n' "${entry_name}" | sed 's/^0*//')" [[ -n "${number}" ]] || number="0" + task_summary="-" implementations=() [[ -f "${entry}/IMPLEMENTATION.org" ]] && implementations+=("${entry}/IMPLEMENTATION.org") [[ -f "${entry}/IMPLEMENTATION.md" ]] && implementations+=("${entry}/IMPLEMENTATION.md") @@ -139,6 +140,16 @@ for entry in "${entries[@]}"; do else implementation="${implementations[0]}" implementation_name="$(basename "${implementation}")" + read -r implementation_total implementation_completed < <( + awk ' + /^[[:space:]]*[-+*][[:space:]]+\[[ xX]\][[:space:]]/ { + total++ + if ($0 ~ /^[[:space:]]*[-+*][[:space:]]+\[[xX]\][[:space:]]/) completed++ + } + END { printf "%d %d\n", total, completed } + ' "${implementation}" + ) + task_summary="${implementation_completed}/${implementation_total}" case "${implementation_name}" in IMPLEMENTATION.org) expected_heading="#+TITLE: RFD ${entry_name} implementation checklist"; backlink="[[file:README.adoc][" ;; @@ -173,7 +184,7 @@ for entry in "${entries[@]}"; do failures=$((failures + parser_errors)) state_field="$(printf '%-13s' "${state:-\(missing\)}")" state_text="$(colorize_state "${state}" "${state_field}")" - printf "%-4s %s %-35s %s\n" "${entry_name}" "${state_text}" "${title:-\(missing title\)}" "${labels:-\(missing labels\)}" + printf "%-4s %s %5s %-35s %s\n" "${entry_name}" "${state_text}" "${task_summary}" "${title:-\(missing title\)}" "${labels:-\(missing labels\)}" done if [[ "${found}" -eq 0 ]]; then