image_viewer: Add PDF find in document and internal link jumps

Chrome-parity navigation work for the PDF viewer:

- Find in document (ctrl-f): query editor, match counter, Enter/Shift+Enter
  and ctrl-g navigation, yellow highlights with a stronger tint on the
  active match, and a page jump per match.
- Internal link destinations: pdfium now reports dest_page for GoTo links,
  so clicking a link that points inside the document jumps to that page
  instead of being dropped.
- Space/Shift+Space scroll the viewport a screen at a time, unless the
  find bar has focus so the query stays typeable.
This commit is contained in:
yenru0 committed 2026-09-24 03:45:02 +09:00
1 parent c8b11be511
commit 818b00d47e
9 files changed
+951 -147

No files matched your search

+3
View File
@@ -1626,6 +1626,9 @@
"home": "image_viewer::FirstPage",
"end": "image_viewer::LastPage",
"ctrl-c": "image_viewer::CopyText",
"ctrl-f": "image_viewer::FindInDocument",
"ctrl-g": "image_viewer::FindNext",
"ctrl-shift-g": "image_viewer::FindPrevious",
"ctrl-shift-t": "image_viewer::ToggleTextPanel"
},
},
+3
View File
@@ -1678,6 +1678,9 @@
"home": "image_viewer::FirstPage",
"end": "image_viewer::LastPage",
"cmd-c": "image_viewer::CopyText",
"cmd-f": "image_viewer::FindInDocument",
"cmd-g": "image_viewer::FindNext",
"cmd-shift-g": "image_viewer::FindPrevious",
"cmd-shift-t": "image_viewer::ToggleTextPanel"
},
},
+3
View File
@@ -1601,6 +1601,9 @@
"home": "image_viewer::FirstPage",
"end": "image_viewer::LastPage",
"ctrl-c": "image_viewer::CopyText",
"ctrl-f": "image_viewer::FindInDocument",
"ctrl-g": "image_viewer::FindNext",
"ctrl-shift-g": "image_viewer::FindPrevious",
"ctrl-shift-t": "image_viewer::ToggleTextPanel"
},
},
File diff suppressed because it is too large. Load diff
+21 -3
View File
@@ -336,12 +336,30 @@ mod tests {
let key0 = CacheKey::new(0, 1.0, false);
let key1 = CacheKey::new(1, 1.0, false);
cache.insert(key0, MockCustomPage { id: 100, size: 600_000 });
cache.insert(key1, MockCustomPage { id: 101, size: 600_000 });
cache.insert(
key0,
MockCustomPage {
id: 100,
size: 600_000,
},
);
cache.insert(
key1,
MockCustomPage {
id: 101,
size: 600_000,
},
);
// Total 1.2 MB > 1 MB, key0 must be evicted
assert!(cache.get(&key0).is_none());
assert_eq!(cache.get(&key1), Some(MockCustomPage { id: 101, size: 600_000 }));
assert_eq!(
cache.get(&key1),
Some(MockCustomPage {
id: 101,
size: 600_000
})
);
assert_eq!(cache.len(), 1);
assert_eq!(cache.memory_usage(), 600_000);
}
+2 -2
View File
@@ -19,7 +19,7 @@ pub mod ui;
pub mod view;
pub mod watcher;
pub use cache::{CacheKey, CacheablePage, PageLruCache, RenderedPage, DEFAULT_MEMORY_BUDGET_BYTES};
pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage};
pub use document::{PageDimensions, PdfDocument};
pub use pdfium::{
PdfDocumentDetails, PdfLinkAnnotation, PdfPageDetails, PdfTextSegment, PdfiumEngine,
@@ -27,5 +27,5 @@ pub use pdfium::{
pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions};
pub use settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings};
pub use ui::{PdfToolbarAction, PdfToolbarState};
pub use view::{PdfView, PdfViewEvent, MAX_ZOOM, MIN_ZOOM, PAGE_SPACING_PX, ZOOM_STEP};
pub use view::{MAX_ZOOM, MIN_ZOOM, PAGE_SPACING_PX, PdfView, PdfViewEvent, ZOOM_STEP};
pub use watcher::{PdfReloadDebouncer, ViewerStateSnapshot};
+73 -1
View File
@@ -135,7 +135,9 @@ impl PdfiumEngine {
}
}
log::warn!("Dynamic libpdfium not found in standard system paths; engine will use synthetic fallback");
log::warn!(
"Dynamic libpdfium not found in standard system paths; engine will use synthetic fallback"
);
None
}
@@ -445,7 +447,10 @@ impl PdfiumEngine {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PdfLinkAnnotation {
/// Target URL for external links; empty for internal (in-document) links.
pub url: String,
/// Zero-based target page for internal links; `None` for external links.
pub dest_page: Option<usize>,
pub x: f32,
pub y: f32,
pub width: f32,
@@ -672,6 +677,7 @@ fn extract_page_text_and_links(
links.push(PdfLinkAnnotation {
url: url_clean,
dest_page: None,
x: norm_x,
y: norm_y,
width: norm_w,
@@ -681,6 +687,32 @@ fn extract_page_text_and_links(
}
}
}
if let Some(PdfAction::LocalDestination(dest_action)) = link.action() {
if let Ok(dest_page) = dest_action
.destination()
.and_then(|destination| destination.page_index())
{
if let Ok(rect) = link.rect() {
let left = rect.left().value.min(rect.right().value);
let right = rect.left().value.max(rect.right().value);
let bottom = rect.bottom().value.min(rect.top().value);
let top = rect.bottom().value.max(rect.top().value);
let (norm_x, norm_y, norm_w, norm_h) =
normalize_coordinates(left, right, bottom, top, page_w, page_h);
links.push(PdfLinkAnnotation {
url: String::new(),
dest_page: Some(dest_page as usize),
x: norm_x,
y: norm_y,
width: norm_w,
height: norm_h,
});
}
}
}
}
// 3. Links from page.annotations()
@@ -708,6 +740,7 @@ fn extract_page_text_and_links(
if !exists {
links.push(PdfLinkAnnotation {
url: url_clean,
dest_page: None,
x: norm_x,
y: norm_y,
width: norm_w,
@@ -720,6 +753,44 @@ fn extract_page_text_and_links(
}
}
}
if let Some(link_annot) = annot.as_link_annotation() {
if let Ok(link) = link_annot.link() {
if let Some(PdfAction::LocalDestination(dest_action)) = link.action() {
if let Ok(dest_page) = dest_action
.destination()
.and_then(|destination| destination.page_index())
{
if let Ok(rect) = link.rect() {
let left = rect.left().value.min(rect.right().value);
let right = rect.left().value.max(rect.right().value);
let bottom = rect.bottom().value.min(rect.top().value);
let top = rect.bottom().value.max(rect.top().value);
let (norm_x, norm_y, norm_w, norm_h) =
normalize_coordinates(left, right, bottom, top, page_w, page_h);
let dest_page = dest_page as usize;
let exists = links.iter().any(|l| {
l.dest_page == Some(dest_page)
&& (l.x - norm_x).abs() < 0.01
&& (l.y - norm_y).abs() < 0.01
});
if !exists {
links.push(PdfLinkAnnotation {
url: String::new(),
dest_page: Some(dest_page),
x: norm_x,
y: norm_y,
width: norm_w,
height: norm_h,
});
}
}
}
}
}
}
}
// 4. Text URLs from segments (heuristic for raw text URLs)
@@ -737,6 +808,7 @@ fn extract_page_text_and_links(
if !exists {
links.push(PdfLinkAnnotation {
url,
dest_page: None,
x: seg.x,
y: seg.y,
width: seg.width,
+1 -1
View File
@@ -4,7 +4,7 @@ use anyhow::{Context as _, Result};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::cache::{CacheKey, PageLruCache, RenderedPage, DEFAULT_MEMORY_BUDGET_BYTES};
use crate::cache::{CacheKey, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage};
use crate::document::{PageDimensions, PdfDocument};
use crate::pdfium::PdfiumEngine;
use crate::rasterizer::RasterizerOptions;
+1 -1
View File
@@ -9,8 +9,8 @@
//! 3. Active page index and selection states.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
/// Snapshot of viewer position to restore seamlessly across document reloads.