From 46d628e9c7928bd8b1e2fc4a975bf24c9ae6f930 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Thu, 24 Sep 2026 04:09:43 +0900 Subject: [PATCH] image_viewer: Select PDF text by character - Selections are character ranges backed by pdfium character boxes, fetched per page on demand and cached, so a drag stops mid-word and copy yields the exact substring. - Double clicks select the word under the pointer and right clicks outside the selection select the word before opening the context menu. - Highlight runs merge neighbouring characters on a line, keeping the painted quad count low, and margins clicks resolve to the nearest glyph. - Saving a copy of the document is available from the toolbar, the context menu, and ctrl-s; picking the opened file itself is a no-op. --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/image_viewer/src/image_viewer.rs | 1072 ++++++++++++++--------- crates/pdf_viewer/src/pdf_viewer.rs | 2 +- crates/pdf_viewer/src/pdfium.rs | 92 ++ 6 files changed, 742 insertions(+), 427 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 196f153682..a46d7d4698 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1629,6 +1629,7 @@ "ctrl-f": "image_viewer::FindInDocument", "ctrl-g": "image_viewer::FindNext", "ctrl-shift-g": "image_viewer::FindPrevious", + "ctrl-s": "image_viewer::SaveCopyAs", "ctrl-shift-t": "image_viewer::ToggleTextPanel" }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 234b6c2b52..975cf8493e 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1681,6 +1681,7 @@ "cmd-f": "image_viewer::FindInDocument", "cmd-g": "image_viewer::FindNext", "cmd-shift-g": "image_viewer::FindPrevious", + "cmd-s": "image_viewer::SaveCopyAs", "cmd-shift-t": "image_viewer::ToggleTextPanel" }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index a6d7509d31..e8532142cc 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1604,6 +1604,7 @@ "ctrl-f": "image_viewer::FindInDocument", "ctrl-g": "image_viewer::FindNext", "ctrl-shift-g": "image_viewer::FindPrevious", + "ctrl-s": "image_viewer::SaveCopyAs", "ctrl-shift-t": "image_viewer::ToggleTextPanel" }, }, diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index adcc1c6f4f..a89a8c3c9f 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -85,6 +85,8 @@ actions!( RotateClockwise, /// Rotate the pages a quarter turn counterclockwise (PDF). RotateCounterclockwise, + /// Save a copy of the PDF to a chosen location (PDF). + SaveCopyAs, /// Open the find-in-document bar (PDF). FindInDocument, /// Jump to the next find-in-document match (PDF). @@ -117,34 +119,37 @@ const THUMBNAIL_PANEL_WIDTH: f32 = 150.0; const OUTLINE_PANEL_WIDTH: f32 = 220.0; const OUTLINE_INDENT: f32 = 12.0; const PROPERTIES_PANEL_WIDTH: f32 = 220.0; +const MAX_SAME_LINE_DISTANCE: f32 = 0.15; // Page-width fraction for line-end fallbacks. +const MAX_NEAREST_DISTANCE_SQUARED: f32 = 0.0064; // 8% of a page, for margin clicks. +/// A character range selected across one or more pages. #[derive(Clone, Debug, PartialEq)] pub struct PdfSelection { pub start_page: usize, - pub start_seg: usize, + pub start_char: usize, pub end_page: usize, - pub end_seg: usize, + pub end_char: usize, pub text: String, } impl PdfSelection { pub fn min_endpoint(&self) -> (usize, usize) { if self.start_page < self.end_page - || (self.start_page == self.end_page && self.start_seg <= self.end_seg) + || (self.start_page == self.end_page && self.start_char <= self.end_char) { - (self.start_page, self.start_seg) + (self.start_page, self.start_char) } else { - (self.end_page, self.end_seg) + (self.end_page, self.end_char) } } pub fn max_endpoint(&self) -> (usize, usize) { if self.start_page < self.end_page - || (self.start_page == self.end_page && self.start_seg <= self.end_seg) + || (self.start_page == self.end_page && self.start_char <= self.end_char) { - (self.end_page, self.end_seg) + (self.end_page, self.end_char) } else { - (self.start_page, self.start_seg) + (self.start_page, self.start_char) } } } @@ -153,12 +158,13 @@ impl PdfSelection { #[derive(Clone, Debug, PartialEq)] struct PdfFindMatch { page_index: usize, - rects: Vec, + rects: Vec, } -/// Page-relative normalized box of one highlight quad. +/// Page-relative normalized box used for every overlay drawn on a page: +/// find hits, selection runs, and link hotspots. #[derive(Clone, Copy, Debug, PartialEq)] -struct PdfFindRect { +struct NormRect { x: f32, y: f32, width: f32, @@ -206,6 +212,8 @@ pub struct ImageView { pdf_autoscroll_task: Option>, context_menu: Option<(Entity, Point, Subscription)>, page_bounds: Rc>>>, + /// Character boxes per page, filled on demand for selection hit testing. + pdf_char_cache: RefCell>>>, pdf_find: Option, show_thumbnail_panel: bool, show_outline_panel: bool, @@ -345,6 +353,7 @@ impl ImageView { pdf_autoscroll_task: None, context_menu: None, page_bounds: Rc::new(RefCell::new(HashMap::default())), + pdf_char_cache: RefCell::new(HashMap::default()), pdf_find: None, show_thumbnail_panel: false, show_outline_panel: false, @@ -379,6 +388,7 @@ impl ImageView { self.pdf_thumbnail_cache.clear(); self.pdf_outline = None; self.pdf_metadata = None; + self.pdf_char_cache.borrow_mut().clear(); if self.pdf_find.is_some() { self.refresh_pdf_find_matches(cx); } @@ -391,6 +401,7 @@ impl ImageView { self.pdf_thumbnail_cache.clear(); self.pdf_outline = None; self.pdf_metadata = None; + self.pdf_char_cache.borrow_mut().clear(); if self.pdf_find.is_some() { self.refresh_pdf_find_matches(cx); } @@ -500,18 +511,16 @@ impl ImageView { } fn ensure_pdf_selection_text(&mut self, cx: &mut Context) { - if let Some(ref mut sel) = self.pdf_selection { - if sel.text.is_empty() { - let item = self.image_item.read(cx); - if let Some(ref pdf_info) = item.pdf_info { - sel.text = Self::collect_selection_text( - &pdf_info.pages, - sel.start_page, - sel.start_seg, - sel.end_page, - sel.end_seg, - ); - } + let Some(selection) = self.pdf_selection.clone() else { + return; + }; + if !selection.text.is_empty() { + return; + } + + if let Some(text) = self.selection_text(&selection, cx) { + if let Some(stored) = self.pdf_selection.as_mut() { + stored.text = text; } } } @@ -526,28 +535,32 @@ impl ImageView { } fn select_all(&mut self, _: &SelectAll, _window: &mut Window, cx: &mut Context) { - let item = self.image_item.read(cx); - if let Some(ref pdf_info) = item.pdf_info { - let cur_page = self.visible_pdf_page(cx); - let target_page = self - .pdf_selection - .as_ref() - .map(|s| s.start_page) - .unwrap_or(cur_page); - if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == target_page) { - let total_segs = page.text_segments.len(); - if total_segs > 0 { - self.pdf_selection = Some(PdfSelection { - start_page: target_page, - start_seg: 0, - end_page: target_page, - end_seg: total_segs.saturating_sub(1), - text: page.page_text.clone(), - }); - cx.notify(); - } - } + let page_index = self.visible_pdf_page(cx); + let Some(pdf_bytes) = self + .image_item + .read(cx) + .pdf_info + .as_ref() + .map(|info| info.pdf_bytes.clone()) + else { + return; + }; + let Some(boxes) = self.char_boxes_for_page(&pdf_bytes, page_index) else { + return; + }; + if boxes.is_empty() { + return; } + + self.pdf_selection = Some(PdfSelection { + start_page: page_index, + start_char: 0, + end_page: page_index, + end_char: boxes.len() - 1, + text: String::new(), + }); + self.ensure_pdf_selection_text(cx); + cx.notify(); } fn deploy_context_menu( @@ -579,6 +592,10 @@ impl ImageView { .action("Reset Zoom", Box::new(ResetZoom)) .action("Fit to View", Box::new(FitToView)) .separator() + .when(is_pdf, |menu| { + menu.action("Save a Copy As…", Box::new(SaveCopyAs)) + .separator() + }) .action("Reveal in File Manager", Box::new(RevealInFileManager)) }) }); @@ -592,110 +609,218 @@ impl ImageView { cx.notify(); } - fn find_segment_at( - segments: &[pdf_viewer::PdfTextSegment], - norm_x: f32, - norm_y: f32, - ) -> Option { - if segments.is_empty() { + /// Character boxes for a page, extracted through the engine on first use. Character + /// indices are the basis for selections, so they are cached per page. + fn char_boxes_for_page( + &self, + pdf_bytes: &[u8], + page_index: usize, + ) -> Option>> { + if let Some(cached) = self.pdf_char_cache.borrow().get(&page_index) { + return Some(cached.clone()); + } + + let boxes = PdfiumEngine::new() + .extract_char_boxes_from_bytes(pdf_bytes, page_index) + .log_err()?; + let boxes = Arc::new(boxes); + self.pdf_char_cache + .borrow_mut() + .insert(page_index, boxes.clone()); + Some(boxes) + } + + /// Character under a normalized page-space point. Falls back to the nearest character + /// on the same line so dragging past the end of a line still selects text, and to the + /// nearest character overall when the point sits in the page margins. + fn find_char_at(boxes: &[pdf_viewer::PdfCharBox], norm_x: f32, norm_y: f32) -> Option { + if boxes.is_empty() { return None; } - // 1. Exact bounding box hit - for (i, seg) in segments.iter().enumerate() { - if norm_x >= seg.x - && norm_x <= seg.x + seg.width - && norm_y >= seg.y - && norm_y <= seg.y + seg.height + let mut same_line: Option<(usize, f32, f32)> = None; + let mut nearest: Option<(usize, f32)> = None; + for (index, char_box) in boxes.iter().enumerate() { + if char_box.width <= 0.0 || char_box.height <= 0.0 { + continue; + } + + if norm_x >= char_box.x + && norm_x <= char_box.x + char_box.width + && norm_y >= char_box.y + && norm_y <= char_box.y + char_box.height { - return Some(i); + return Some(index); + } + + let center_x = char_box.x + char_box.width * 0.5; + let center_y = char_box.y + char_box.height * 0.5; + let line_delta = (norm_y - center_y).abs(); + let distance = (norm_x - center_x).abs(); + + if line_delta <= char_box.height * 0.6 + && distance <= MAX_SAME_LINE_DISTANCE + && same_line.is_none_or(|(_, best_distance, best_line)| { + (line_delta, distance) < (best_line, best_distance) + }) + { + same_line = Some((index, distance, line_delta)); + } + + let squared = distance.powi(2) + line_delta.powi(2); + if squared <= MAX_NEAREST_DISTANCE_SQUARED + && nearest.is_none_or(|(_, best)| squared < best) + { + nearest = Some((index, squared)); } } - // 2. Line-level hit with horizontal proximity threshold - const MAX_LINE_PROXIMITY: f32 = 0.05; - let mut closest_on_line: Option<(usize, f32)> = None; - for (i, seg) in segments.iter().enumerate() { - let line_top = seg.y - seg.height * 0.2; - let line_bottom = seg.y + seg.height * 1.2; - if norm_y >= line_top && norm_y <= line_bottom { - let seg_center_x = seg.x + seg.width * 0.5; - let dist_x = (norm_x - seg_center_x).abs(); - let allowed_dist = MAX_LINE_PROXIMITY.max(seg.width * 1.5); - if dist_x <= allowed_dist - && closest_on_line.as_ref().map_or(true, |(_, d)| dist_x < *d) - { - closest_on_line = Some((i, dist_x)); - } - } - } - if let Some((idx, _)) = closest_on_line { - return Some(idx); - } - - // 3. Closest segment overall within strict proximity threshold (within 5% distance) - const MAX_OVERALL_DIST_SQ: f32 = 0.05 * 0.05; - let mut closest: Option<(usize, f32)> = None; - for (i, seg) in segments.iter().enumerate() { - let center_x = seg.x + seg.width * 0.5; - let center_y = seg.y + seg.height * 0.5; - let dist_sq = (norm_x - center_x).powi(2) + (norm_y - center_y).powi(2); - if dist_sq <= MAX_OVERALL_DIST_SQ - && closest.as_ref().map_or(true, |(_, d)| dist_sq < *d) - { - closest = Some((i, dist_sq)); - } - } - closest.map(|(idx, _)| idx) + same_line + .map(|(index, ..)| index) + .or_else(|| nearest.map(|(index, _)| index)) } - fn collect_selection_text( - pages: &[PdfPageEntry], - start_page: usize, - start_seg: usize, - end_page: usize, - end_seg: usize, - ) -> String { - let (min_p, min_s, max_p, max_s) = - if start_page < end_page || (start_page == end_page && start_seg <= end_seg) { - (start_page, start_seg, end_page, end_seg) - } else { - (end_page, end_seg, start_page, start_seg) - }; + /// Character range of the word containing `index`, stopping at whitespace or a line + /// break so double clicks select a word rather than a whole line. + fn word_char_range(boxes: &[pdf_viewer::PdfCharBox], index: usize) -> (usize, usize) { + if boxes.is_empty() { + return (0, 0); + } + let index = index.min(boxes.len() - 1); + if boxes[index].character.is_whitespace() { + return (index, index); + } - let mut result_parts = Vec::new(); - for p in min_p..=max_p { - if let Some(page) = pages.iter().find(|page| page.page_index == p) { - let segs = &page.text_segments; - if segs.is_empty() { + let mut first = index; + while first > 0 + && !boxes[first - 1].character.is_whitespace() + && Self::on_same_line(&boxes[first - 1], &boxes[first]) + { + first -= 1; + } + let mut last = index; + while last + 1 < boxes.len() + && !boxes[last + 1].character.is_whitespace() + && Self::on_same_line(&boxes[last], &boxes[last + 1]) + { + last += 1; + } + (first, last) + } + + fn on_same_line(a: &pdf_viewer::PdfCharBox, b: &pdf_viewer::PdfCharBox) -> bool { + (a.y - b.y).abs() <= a.height.max(b.height) * 0.5 + } + + /// Merges the characters in `start..=end` into quads, joining neighbours on the same + /// line so a selection paints a few runs instead of one quad per character. + fn merge_char_runs( + boxes: &[pdf_viewer::PdfCharBox], + start: usize, + end: usize, + ) -> Vec { + let mut rects: Vec = Vec::new(); + if boxes.is_empty() || start > end { + return rects; + } + + for index in start..=end.min(boxes.len() - 1) { + let char_box = &boxes[index]; + if char_box.width <= 0.0 || char_box.height <= 0.0 { + continue; + } + + if let Some(run) = rects.last_mut() { + let same_line = (run.y - char_box.y).abs() <= char_box.height.max(run.height) * 0.5; + let touches = + (char_box.x - (run.x + run.width)).abs() <= char_box.width.max(run.width) * 0.6; + if same_line && touches { + run.width = (run.x + run.width).max(char_box.x + char_box.width) - run.x; + run.height = run.height.max(char_box.height); continue; } - let first_idx = if p == min_p { - min_s.min(segs.len().saturating_sub(1)) - } else { - 0 - }; - let last_idx = if p == max_p { - max_s.min(segs.len().saturating_sub(1)) - } else { - segs.len().saturating_sub(1) - }; - if first_idx <= last_idx { - let page_text = segs[first_idx..=last_idx] - .iter() - .map(|s| s.text.as_str()) - .collect::>() - .join(" "); - if !page_text.is_empty() { - result_parts.push(page_text); - } - } } + + rects.push(NormRect { + x: char_box.x, + y: char_box.y, + width: char_box.width, + height: char_box.height, + }); } - result_parts.join("\n\n") + + rects } - fn find_page_and_segment_at( + /// Joins the characters a selection covers. Pages are separated the way the extracted + /// text is, and pages without extracted characters are skipped. + fn selection_text_for_pages( + page_chars: &[(usize, &[pdf_viewer::PdfCharBox])], + selection: &PdfSelection, + ) -> String { + let (min_page, min_char) = selection.min_endpoint(); + let (max_page, max_char) = selection.max_endpoint(); + + let mut parts = Vec::new(); + for (page_index, boxes) in page_chars { + if *page_index < min_page || *page_index > max_page || boxes.is_empty() { + continue; + } + + let first = if *page_index == min_page { + min_char.min(boxes.len() - 1) + } else { + 0 + }; + let last = if *page_index == max_page { + max_char.min(boxes.len() - 1) + } else { + boxes.len() - 1 + }; + if first > last { + continue; + } + + let text: String = boxes[first..=last] + .iter() + .map(|char_box| char_box.character) + .collect(); + if !text.trim().is_empty() { + parts.push(text); + } + } + + parts.join("\n\n") + } + + /// Text covered by a selection, pulling the character boxes of every page it spans. + fn selection_text(&self, selection: &PdfSelection, cx: &App) -> Option { + let pdf_bytes = self + .image_item + .read(cx) + .pdf_info + .as_ref()? + .pdf_bytes + .clone(); + let (min_page, _) = selection.min_endpoint(); + let (max_page, _) = selection.max_endpoint(); + + let mut cached = Vec::new(); + for page_index in min_page..=max_page { + if let Some(boxes) = self.char_boxes_for_page(&pdf_bytes, page_index) { + cached.push((page_index, boxes)); + } + } + + let page_chars: Vec<(usize, &[pdf_viewer::PdfCharBox])> = cached + .iter() + .map(|(page_index, boxes)| (*page_index, boxes.as_slice())) + .collect(); + Some(Self::selection_text_for_pages(&page_chars, selection)) + } + + /// Page and character index under a cursor position, using the laid out page bounds. + fn find_page_and_char_at( &self, cursor_pos: Point, pdf_info: &PdfPageInfo, @@ -705,37 +830,40 @@ impl ImageView { return None; } - // 1. Direct hit inside page bounds - for (&page_idx, &bounds) in page_bounds.iter() { - if bounds.contains(&cursor_pos) { - if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_idx) { - if page.text_segments.is_empty() { - return Some((page_idx, 0)); - } - let b_w: f32 = bounds.size.width.into(); - let b_h: f32 = bounds.size.height.into(); - let norm_x = if b_w > 0.0 { - let b_ox: f32 = bounds.origin.x.into(); - let p_x: f32 = cursor_pos.x.into(); - ((p_x - b_ox) / b_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_y = if b_h > 0.0 { - let b_oy: f32 = bounds.origin.y.into(); - let p_y: f32 = cursor_pos.y.into(); - ((p_y - b_oy) / b_h).clamp(0.0, 1.0) - } else { - 0.0 - }; - let (norm_x, norm_y) = - Self::unrotate_normalized_point(norm_x, norm_y, self.page_rotation); - if let Some(seg_idx) = - Self::find_segment_at(&page.text_segments, norm_x, norm_y) - { - return Some((page_idx, seg_idx)); - } + let nearest_on_page = + |page_index: usize, bounds: Bounds| -> Option<(usize, usize)> { + let boxes = self.char_boxes_for_page(&pdf_info.pdf_bytes, page_index)?; + if boxes.is_empty() { + return None; } + + let b_w: f32 = bounds.size.width.into(); + let b_h: f32 = bounds.size.height.into(); + let b_ox: f32 = bounds.origin.x.into(); + let b_oy: f32 = bounds.origin.y.into(); + let p_x: f32 = cursor_pos.x.into(); + let p_y: f32 = cursor_pos.y.into(); + let norm_x = if b_w > 0.0 { + ((p_x - b_ox) / b_w).clamp(0.0, 1.0) + } else { + 0.0 + }; + let norm_y = if b_h > 0.0 { + ((p_y - b_oy) / b_h).clamp(0.0, 1.0) + } else { + 0.0 + }; + let (norm_x, norm_y) = + Self::unrotate_normalized_point(norm_x, norm_y, self.page_rotation); + + Self::find_char_at(&boxes, norm_x, norm_y) + .or(Some(0)) + .map(|index| (page_index, index)) + }; + + for (&page_index, &bounds) in page_bounds.iter() { + if bounds.contains(&cursor_pos) { + return nearest_on_page(page_index, bounds); } } @@ -743,66 +871,21 @@ impl ImageView { page_bounds.iter().map(|(&idx, &b)| (idx, b)).collect(); sorted_pages.sort_by_key(|(idx, _)| *idx); - let first = sorted_pages.first()?; - let last = sorted_pages.last()?; - + let first = *sorted_pages.first()?; + let last = *sorted_pages.last()?; if cursor_pos.y < first.1.origin.y { - return Some((first.0, 0)); + return nearest_on_page(first.0, first.1); } if cursor_pos.y > last.1.origin.y + last.1.size.height { - let last_page = pdf_info.pages.iter().find(|p| p.page_index == last.0)?; - let last_seg = last_page.text_segments.len().saturating_sub(1); - return Some((last.0, last_seg)); + return nearest_on_page(last.0, last.1); } - // 2. Vertical overlap - for (page_idx, bounds) in &sorted_pages { + for &(page_index, bounds) in &sorted_pages { let top = bounds.origin.y; let bottom = bounds.origin.y + bounds.size.height; if cursor_pos.y >= top && cursor_pos.y <= bottom { - if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == *page_idx) { - if page.text_segments.is_empty() { - return Some((*page_idx, 0)); - } - let b_w: f32 = bounds.size.width.into(); - let b_h: f32 = bounds.size.height.into(); - let norm_x = if b_w > 0.0 { - let b_ox: f32 = bounds.origin.x.into(); - let p_x: f32 = cursor_pos.x.into(); - ((p_x - b_ox) / b_w).clamp(0.0, 1.0) - } else { - 0.0 - }; - let norm_y = if b_h > 0.0 { - let b_oy: f32 = bounds.origin.y.into(); - let p_y: f32 = cursor_pos.y.into(); - ((p_y - b_oy) / b_h).clamp(0.0, 1.0) - } else { - 0.0 - }; - let (norm_x, norm_y) = - Self::unrotate_normalized_point(norm_x, norm_y, self.page_rotation); - if let Some(seg_idx) = - Self::find_segment_at(&page.text_segments, norm_x, norm_y) - { - return Some((*page_idx, seg_idx)); - } - } - } - } - - // 3. Gap between two adjacent pages - for window in sorted_pages.windows(2) { - let (p1, b1) = window[0]; - let (p2, b2) = window[1]; - let b1_bot = b1.origin.y + b1.size.height; - let b2_top = b2.origin.y; - if cursor_pos.y >= b1_bot && cursor_pos.y <= b2_top { - if cursor_pos.y - b1_bot < b2_top - cursor_pos.y { - let page = pdf_info.pages.iter().find(|p| p.page_index == p1)?; - return Some((p1, page.text_segments.len().saturating_sub(1))); - } else { - return Some((p2, 0)); + if let Some(found) = nearest_on_page(page_index, bounds) { + return Some(found); } } } @@ -815,12 +898,12 @@ impl ImageView { let Some(ref pdf_info) = item.pdf_info else { return; }; - if let Some((page_idx, seg_idx)) = self.find_page_and_segment_at(cursor_pos, pdf_info) { - if let Some(ref mut sel) = self.pdf_selection { - if sel.end_page != page_idx || sel.end_seg != seg_idx { - sel.end_page = page_idx; - sel.end_seg = seg_idx; - sel.text.clear(); + if let Some((page_index, char_index)) = self.find_page_and_char_at(cursor_pos, pdf_info) { + if let Some(ref mut selection) = self.pdf_selection { + if selection.end_page != page_index || selection.end_char != char_index { + selection.end_page = page_index; + selection.end_char = char_index; + selection.text.clear(); cx.notify(); } } @@ -957,29 +1040,6 @@ impl ImageView { cx: &mut Context, ) { let bounds = self.page_bounds.borrow().get(&page_index).copied(); - let item = self.image_item.read(cx); - let Some(ref pdf_info) = item.pdf_info else { - return; - }; - let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) else { - return; - }; - - if click_count >= 3 { - self.pdf_mouse_down = false; - self.pdf_drag_position = None; - self.pdf_autoscroll_task = None; - self.pdf_selection = Some(PdfSelection { - start_page: page_index, - start_seg: 0, - end_page: page_index, - end_seg: page.text_segments.len().saturating_sub(1), - text: page.page_text.clone(), - }); - cx.notify(); - return; - } - let Some(bounds) = bounds else { return; }; @@ -997,22 +1057,44 @@ impl ImageView { let norm_y = ((p_y - b_oy) / b_h).clamp(0.0, 1.0); let (norm_x, norm_y) = Self::unrotate_normalized_point(norm_x, norm_y, self.page_rotation); - if let Some(idx) = Self::find_segment_at(&page.text_segments, norm_x, norm_y) { - self.pdf_mouse_down = true; - self.pdf_drag_position = Some(click_pos); - let seg = &page.text_segments[idx]; - self.pdf_selection = Some(PdfSelection { - start_page: page_index, - start_seg: idx, - end_page: page_index, - end_seg: idx, - text: seg.text.clone(), - }); - } else { - self.pdf_mouse_down = false; - self.pdf_selection = None; - self.pdf_drag_position = None; - self.pdf_autoscroll_task = None; + let hit = { + let item = self.image_item.read(cx); + let Some(ref pdf_info) = item.pdf_info else { + return; + }; + self.char_boxes_for_page(&pdf_info.pdf_bytes, page_index) + .and_then(|boxes| { + Self::find_char_at(&boxes, norm_x, norm_y).map(|index| (index, boxes)) + }) + }; + + match hit { + Some((index, boxes)) => { + // Double clicks take the whole word, triple clicks the whole page. + let (first, last) = match click_count { + 0 | 1 => (index, index), + 2 => Self::word_char_range(&boxes, index), + _ => (0, boxes.len().saturating_sub(1)), + }; + + self.pdf_mouse_down = click_count < 3; + self.pdf_drag_position = (click_count < 3).then_some(click_pos); + self.pdf_autoscroll_task = None; + self.pdf_selection = Some(PdfSelection { + start_page: page_index, + start_char: first, + end_page: page_index, + end_char: last, + text: String::new(), + }); + self.ensure_pdf_selection_text(cx); + } + None => { + self.pdf_mouse_down = false; + self.pdf_selection = None; + self.pdf_drag_position = None; + self.pdf_autoscroll_task = None; + } } cx.notify(); } @@ -1034,7 +1116,9 @@ impl ImageView { window: &mut Window, cx: &mut Context, ) { - if let Some(bounds) = self.page_bounds.borrow().get(&page_index).copied() { + let bounds = self.page_bounds.borrow().get(&page_index).copied(); + + if let Some(bounds) = bounds { let b_w: f32 = bounds.size.width.into(); let b_h: f32 = bounds.size.height.into(); if b_w > 0.0 && b_h > 0.0 { @@ -1044,38 +1128,45 @@ impl ImageView { let p_y: f32 = click_pos.y.into(); let norm_x = ((p_x - b_ox) / b_w).clamp(0.0, 1.0); let norm_y = ((p_y - b_oy) / b_h).clamp(0.0, 1.0); + let (norm_x, norm_y) = + Self::unrotate_normalized_point(norm_x, norm_y, self.page_rotation); - let item = self.image_item.read(cx); - if let Some(ref pdf_info) = item.pdf_info { - if let Some(page) = pdf_info.pages.iter().find(|p| p.page_index == page_index) { - if let Some(seg_idx) = - Self::find_segment_at(&page.text_segments, norm_x, norm_y) - { - let need_select = self.pdf_selection.as_ref().map_or(true, |s| { - let (min_p, min_s) = s.min_endpoint(); - let (max_p, max_s) = s.max_endpoint(); - if page_index < min_p || page_index > max_p { - true - } else if page_index == min_p && page_index == max_p { - seg_idx < min_s || seg_idx > max_s - } else if page_index == min_p { - seg_idx < min_s - } else if page_index == max_p { - seg_idx > max_s - } else { - false - } - }); - if need_select { - self.pdf_selection = Some(PdfSelection { - start_page: page_index, - start_seg: seg_idx, - end_page: page_index, - end_seg: seg_idx, - text: page.text_segments[seg_idx].text.clone(), - }); - } + let hit = { + let item = self.image_item.read(cx); + let Some(ref pdf_info) = item.pdf_info else { + self.deploy_context_menu(click_pos, window, cx); + return; + }; + self.char_boxes_for_page(&pdf_info.pdf_bytes, page_index) + .and_then(|boxes| { + Self::find_char_at(&boxes, norm_x, norm_y).map(|index| (index, boxes)) + }) + }; + + // Right clicking outside the selection selects the word under the pointer, + // matching the browser viewer's context menu behaviour. + if let Some((index, boxes)) = hit { + let within_selection = self.pdf_selection.as_ref().is_some_and(|selection| { + let (min_page, min_char) = selection.min_endpoint(); + let (max_page, max_char) = selection.max_endpoint(); + if page_index < min_page || page_index > max_page { + return false; } + let after_start = page_index > min_page || index >= min_char; + let before_end = page_index < max_page || index <= max_char; + after_start && before_end + }); + + if !within_selection { + let (first, last) = Self::word_char_range(&boxes, index); + self.pdf_selection = Some(PdfSelection { + start_page: page_index, + start_char: first, + end_page: page_index, + end_char: last, + text: String::new(), + }); + self.ensure_pdf_selection_text(cx); } } } @@ -1162,7 +1253,7 @@ impl ImageView { spans: &[(usize, usize)], start: usize, length: usize, - ) -> Option> { + ) -> Option> { let end = start + length; let first = spans.iter().position(|(_, span_end)| *span_end > start)?; let last = spans @@ -1185,7 +1276,7 @@ impl ImageView { continue; } - rects.push(PdfFindRect { + rects.push(NormRect { x: segment.x + segment.width * (trim_start / span_len), y: segment.y, width: segment.width * width_fraction, @@ -1928,6 +2019,58 @@ impl ImageView { } } + /// Writes a copy of the document to a location the user picks, mirroring the + /// browser viewer's download action. The opened file is left untouched. + fn save_copy_as(&mut self, _: &SaveCopyAs, _window: &mut Window, cx: &mut Context) { + let source_path = self.image_item.read(cx).abs_path(cx); + let Some(bytes) = self + .image_item + .read(cx) + .pdf_info + .as_ref() + .map(|info| info.pdf_bytes.clone()) + else { + return; + }; + + let suggested_name = source_path + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "document.pdf".to_string()); + let directory = source_path + .as_ref() + .and_then(|path| path.parent()) + .map(Path::to_path_buf) + .unwrap_or_default(); + + let prompt = cx.prompt_for_new_path(&directory, Some(&suggested_name)); + cx.spawn(async move |_, cx| { + let Some(target) = prompt.await?? else { + return anyhow::Ok(()); + }; + cx.background_spawn(async move { + Self::write_pdf_copy(bytes.as_ref(), source_path.as_deref(), &target)?; + anyhow::Ok(()) + }) + .await + }) + .detach_and_log_err(cx); + } + + /// Writes the document bytes to `target`. Picking the opened file itself is a + /// no-op, so a stray confirmation cannot truncate the document being viewed. + fn write_pdf_copy( + bytes: &[u8], + source_path: Option<&Path>, + target: &Path, + ) -> std::io::Result<()> { + if source_path == Some(target) { + return Ok(()); + } + std::fs::write(target, bytes) + } + fn reveal_in_file_manager( &mut self, _: &RevealInFileManager, @@ -2390,6 +2533,7 @@ impl Item for ImageView { pdf_autoscroll_task: None, context_menu: None, page_bounds: Rc::new(RefCell::new(HashMap::default())), + pdf_char_cache: RefCell::new(HashMap::default()), pdf_find: None, show_thumbnail_panel: false, show_outline_panel: false, @@ -2567,6 +2711,7 @@ impl Render for ImageView { .on_action(cx.listener(Self::toggle_properties_panel)) .on_action(cx.listener(Self::rotate_clockwise)) .on_action(cx.listener(Self::rotate_counterclockwise)) + .on_action(cx.listener(Self::save_copy_as)) .on_action(cx.listener(Self::select_all)) .on_action(cx.listener(Self::open_pdf_find)) .on_action(cx.listener(Self::find_next_match)) @@ -2608,6 +2753,7 @@ impl Render for ImageView { let total_pages = pdf_info.total_pages; let presentation_mode = self.presentation_mode; let rotation = self.page_rotation; + let pdf_bytes = pdf_info.pdf_bytes.clone(); let current_page = self.visible_pdf_page(cx); let main_pdf_view = div() @@ -2653,9 +2799,7 @@ impl Render for ImageView { let page_w = px(page_w_val); let page_h = px(page_h_val); let page_idx = page.page_index; - let segments_arc = Arc::new(page.text_segments); let links = page.links; - let pdf_selection = self.pdf_selection.clone(); let page_img = page_images.remove(&page_idx).or_else(|| page.image.take()); v_flex() @@ -2696,24 +2840,44 @@ impl Render for ImageView { })) .child({ let page_bounds = self.page_bounds.clone(); - let seg_range = pdf_selection.as_ref().and_then(|sel| { - let (min_p, min_s) = sel.min_endpoint(); - let (max_p, max_s) = sel.max_endpoint(); - if page_idx < min_p || page_idx > max_p || segments_arc.is_empty() { - return None; - } - let first_idx = if page_idx == min_p { min_s.min(segments_arc.len().saturating_sub(1)) } else { 0 }; - let last_idx = if page_idx == max_p { max_s.min(segments_arc.len().saturating_sub(1)) } else { segments_arc.len().saturating_sub(1) }; - if first_idx <= last_idx { - Some((first_idx, last_idx)) - } else { - None - } - }); - let segs = segments_arc; + let selection_rects: Vec = self + .pdf_selection + .as_ref() + .map(|selection| { + let (min_page, min_char) = selection.min_endpoint(); + let (max_page, max_char) = selection.max_endpoint(); + if page_idx < min_page || page_idx > max_page { + return Vec::new(); + } + let Some(boxes) = self + .char_boxes_for_page(&pdf_bytes, page_idx) + else { + return Vec::new(); + }; + if boxes.is_empty() { + return Vec::new(); + } + + let first = if page_idx == min_page { + min_char.min(boxes.len() - 1) + } else { + 0 + }; + let last = if page_idx == max_page { + max_char.min(boxes.len() - 1) + } else { + boxes.len() - 1 + }; + if first > last { + return Vec::new(); + } + + Self::merge_char_runs(&boxes, first, last) + }) + .unwrap_or_default(); // Find-in-document highlights for this page, with the // active match marked for a stronger tint. - let find_rects: Vec<(PdfFindRect, bool)> = self + let find_rects: Vec<(NormRect, bool)> = self .pdf_find .as_ref() .map(|find| { @@ -2731,7 +2895,7 @@ impl Render for ImageView { rect.x, rect.y, rect.width, rect.height, rotation, ); ( - PdfFindRect { x, y, width, height }, + NormRect { x, y, width, height }, is_active, ) }) @@ -2771,27 +2935,26 @@ impl Render for ImageView { ); } - if let Some((first_idx, last_idx)) = seg_range { - let highlight_color = gpui::rgba(0x3b82f644); - for seg_idx in first_idx..=last_idx { - if let Some(seg) = segs.get(seg_idx) { - let (seg_x, seg_y, seg_w, seg_h) = - ImageView::rotate_normalized_box( - seg.x, seg.y, seg.width, seg.height, rotation, - ); - let quad_bounds = Bounds { - origin: point( - b_ox + px(seg_x * b_w), - b_oy + px(seg_y * b_h), - ), - size: size( - px(seg_w * b_w), - px(seg_h * b_h), - ), - }; - window.paint_quad(gpui::fill(quad_bounds, highlight_color).corner_radii(px(2.0))); - } - } + let highlight_color = gpui::rgba(0x3b82f644); + for rect in &selection_rects { + let (rect_x, rect_y, rect_w, rect_h) = + ImageView::rotate_normalized_box( + rect.x, rect.y, rect.width, rect.height, rotation, + ); + let quad_bounds = Bounds { + origin: point( + b_ox + px(rect_x * b_w), + b_oy + px(rect_y * b_h), + ), + size: size( + px(rect_w * b_w), + px(rect_h * b_h), + ), + }; + window.paint_quad( + gpui::fill(quad_bounds, highlight_color) + .corner_radii(px(2.0)), + ); } }, ) @@ -3408,6 +3571,23 @@ impl Render for ImageViewToolbarControls { } }), ) + .when(is_pdf, |this| { + let image_view = image_view.downgrade(); + this.child( + IconButton::new("save-copy", IconName::Download) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("Save a Copy As", &SaveCopyAs, cx) + }) + .on_click(move |_, window, cx| { + if let Some(view) = image_view.upgrade() { + view.update(cx, |this, cx| { + this.save_copy_as(&SaveCopyAs, window, cx); + }); + } + }), + ) + }) .child( IconButton::new("fit-to-view", IconName::Maximize) .icon_size(IconSize::Small) @@ -3727,9 +3907,9 @@ mod tests { fn test_pdf_selection_endpoints() { let forward = PdfSelection { start_page: 0, - start_seg: 2, + start_char: 2, end_page: 2, - end_seg: 5, + end_char: 5, text: String::new(), }; assert_eq!(forward.min_endpoint(), (0, 2)); @@ -3737,9 +3917,9 @@ mod tests { let backward = PdfSelection { start_page: 2, - start_seg: 5, + start_char: 5, end_page: 0, - end_seg: 2, + end_char: 2, text: String::new(), }; assert_eq!(backward.min_endpoint(), (0, 2)); @@ -3747,9 +3927,9 @@ mod tests { let same_page_backward = PdfSelection { start_page: 1, - start_seg: 8, + start_char: 8, end_page: 1, - end_seg: 3, + end_char: 3, text: String::new(), }; assert_eq!(same_page_backward.min_endpoint(), (1, 3)); @@ -3757,74 +3937,111 @@ mod tests { } #[test] - fn test_pdf_collect_selection_text_multi_page() { - use pdf_viewer::PdfTextSegment; + fn test_pdf_selection_text_spans_pages() { + let page_one = char_boxes("Hello world", 0); + let page_two = char_boxes("from page two", 0); + let pages: Vec<(usize, &[pdf_viewer::PdfCharBox])> = + vec![(0, page_one.as_slice()), (1, page_two.as_slice())]; - let dummy_img = Arc::new(gpui::RenderImage::new(Vec::new())); + // Partial first and last pages: "world" + the whole second page. + let selection = PdfSelection { + start_page: 0, + start_char: 6, + end_page: 1, + end_char: 12, + text: String::new(), + }; + assert_eq!( + ImageView::selection_text_for_pages(&pages, &selection), + "world\n\nfrom page two" + ); - let pages = vec![ - PdfPageEntry { - page_index: 0, - width: 100, - height: 100, - image: Some(dummy_img.clone()), - page_text: "Hello world".into(), - text_segments: vec![ - PdfTextSegment { - text: "Hello".into(), - x: 0., - y: 0., - width: 10., - height: 10., - }, - PdfTextSegment { - text: "world".into(), - x: 10., - y: 0., - width: 10., - height: 10., - }, - ], - links: vec![], - }, - PdfPageEntry { - page_index: 1, - width: 100, - height: 100, - image: Some(dummy_img), - page_text: "from page two".into(), - text_segments: vec![ - PdfTextSegment { - text: "from".into(), - x: 0., - y: 0., - width: 10., - height: 10., - }, - PdfTextSegment { - text: "page".into(), - x: 10., - y: 0., - width: 10., - height: 10., - }, - PdfTextSegment { - text: "two".into(), - x: 20., - y: 0., - width: 10., - height: 10., - }, - ], - links: vec![], - }, - ]; + // A reversed drag produces the same text. + let reversed = PdfSelection { + start_page: 1, + end_page: 0, + start_char: 12, + end_char: 6, + text: String::new(), + }; + assert_eq!( + ImageView::selection_text_for_pages(&pages, &reversed), + "world\n\nfrom page two" + ); - let text_forward = ImageView::collect_selection_text(&pages, 0, 1, 1, 1); - assert_eq!(text_forward, "world\n\nfrom page"); + // A single character range keeps its exact substring. + let partial = PdfSelection { + start_page: 0, + start_char: 1, + end_page: 0, + end_char: 3, + text: String::new(), + }; + assert_eq!(ImageView::selection_text_for_pages(&pages, &partial), "ell"); + } - let text_backward = ImageView::collect_selection_text(&pages, 1, 1, 0, 1); - assert_eq!(text_backward, "world\n\nfrom page"); + #[test] + fn test_pdf_find_char_at_hits_lines_and_margins() { + let boxes = char_boxes("abcd", 0); + + // Direct hit inside a character box. + assert_eq!(ImageView::find_char_at(&boxes, 0.015, 0.02), Some(1)); + // Past the end of the line stays on that line. + assert_eq!(ImageView::find_char_at(&boxes, 0.06, 0.02), Some(3)); + // Between lines lands on the nearest line only when it is close enough. + assert_eq!(ImageView::find_char_at(&boxes, 0.015, 0.07), Some(1)); + // Far from the text in both axes selects nothing. + assert_eq!(ImageView::find_char_at(&boxes, 0.9, 0.9), None); + assert_eq!(ImageView::find_char_at(&[], 0.0, 0.0), None); + } + + #[test] + fn test_pdf_merge_char_runs_joins_lines() { + let mut boxes = char_boxes("abcd", 0); + boxes.extend(char_boxes("ef", 1)); + + // Adjacent characters collapse into a single run. + let runs = ImageView::merge_char_runs(&boxes, 0, 3); + assert_eq!(runs.len(), 1, "one run per line"); + assert!((runs[0].x - 0.0).abs() < 1e-6); + assert!( + (runs[0].width - 0.04).abs() < 1e-6, + "width was {}", + runs[0].width + ); + + // A line break starts a new run. + let runs = ImageView::merge_char_runs(&boxes, 0, 5); + assert_eq!(runs.len(), 2, "one run per line"); + assert!((runs[1].y - 0.05).abs() < 1e-6, "y was {}", runs[1].y); + + // Empty and inverted ranges produce nothing. + assert!(ImageView::merge_char_runs(&[], 0, 3).is_empty()); + assert!(ImageView::merge_char_runs(&boxes, 4, 2).is_empty()); + } + + #[test] + fn test_pdf_word_char_range_stops_at_whitespace() { + let boxes = char_boxes("one two", 0); + assert_eq!(ImageView::word_char_range(&boxes, 1), (0, 2)); + assert_eq!(ImageView::word_char_range(&boxes, 5), (4, 6)); + // Hitting the space itself selects only that character. + assert_eq!(ImageView::word_char_range(&boxes, 3), (3, 3)); + assert_eq!(ImageView::word_char_range(&[], 2), (0, 0)); + } + + fn char_boxes(text: &str, line: usize) -> Vec { + // Lays the characters out on a single line: 10 characters per tenth of a page. + text.chars() + .enumerate() + .map(|(index, character)| pdf_viewer::PdfCharBox { + character, + x: index as f32 * 0.01, + y: line as f32 * 0.05, + width: 0.01, + height: 0.04, + }) + .collect() } fn find_test_page( @@ -3896,6 +4113,32 @@ mod tests { } } + #[test] + fn test_pdf_save_copy_writes_and_skips_source() { + let dir = std::env::temp_dir().join(format!("gwen-pdf-copy-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir should be created"); + let source = dir.join("source.pdf"); + std::fs::write(&source, b"original").expect("source should be written"); + let target = dir.join("copy.pdf"); + + ImageView::write_pdf_copy(b"%PDF-1.7 copy", Some(&source), &target) + .expect("the copy should be written"); + assert_eq!( + std::fs::read(&target).expect("copy should exist"), + b"%PDF-1.7 copy" + ); + + // Saving onto the opened file must leave it untouched. + ImageView::write_pdf_copy(b"truncated", Some(&source), &source) + .expect("self copy should be a no-op"); + assert_eq!( + std::fs::read(&source).expect("source should still exist"), + b"original" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn test_pdf_visible_page_range() { assert_eq!(ImageView::visible_page_range(0, 0, 0), None); @@ -3933,7 +4176,7 @@ mod tests { assert_eq!(matches[0].rects.len(), 1); assert_eq!( matches[0].rects[0], - PdfFindRect { + NormRect { x: 0.0, y: 0.0, width: 0.2, @@ -4075,29 +4318,6 @@ mod tests { assert!(!ImageView::is_safe_pdf_url("ms-msdt:/id DevDiagnostic")); } - #[test] - fn test_find_segment_at_whitespace_rejection() { - let segments = vec![pdf_viewer::PdfTextSegment { - text: "Hello".to_string(), - x: 0.2, - y: 0.2, - width: 0.1, - height: 0.05, - }]; - - // Direct hit inside bounding box - assert_eq!(ImageView::find_segment_at(&segments, 0.25, 0.22), Some(0)); - - // Close horizontal proximity on same line - assert_eq!(ImageView::find_segment_at(&segments, 0.31, 0.22), Some(0)); - - // Far away horizontal position on same line (> allowed distance) - assert_eq!(ImageView::find_segment_at(&segments, 0.7, 0.22), None); - - // Far away arbitrary whitespace (e.g. bottom margin) - assert_eq!(ImageView::find_segment_at(&segments, 0.8, 0.8), None); - } - #[test] fn test_pdf_page_cache_by_zoom_bucket() { let mut cache: PageLruCache = PageLruCache::new(1024 * 1024); diff --git a/crates/pdf_viewer/src/pdf_viewer.rs b/crates/pdf_viewer/src/pdf_viewer.rs index a069dbd391..af9caf419b 100644 --- a/crates/pdf_viewer/src/pdf_viewer.rs +++ b/crates/pdf_viewer/src/pdf_viewer.rs @@ -22,7 +22,7 @@ pub mod watcher; pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage}; pub use document::{PageDimensions, PdfDocument}; pub use pdfium::{ - PdfDocumentDetails, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails, + PdfCharBox, PdfDocumentDetails, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails, PdfTextSegment, PdfiumEngine, }; pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions}; diff --git a/crates/pdf_viewer/src/pdfium.rs b/crates/pdf_viewer/src/pdfium.rs index 63b7462caf..ccba5c98b5 100644 --- a/crates/pdf_viewer/src/pdfium.rs +++ b/crates/pdf_viewer/src/pdfium.rs @@ -504,6 +504,83 @@ impl PdfiumEngine { Ok(String::new()) } } + + /// Extracts every character on a zero-indexed page, in pdfium text index order, each with + /// its box in normalized page coordinates (see [`normalize_coordinates`]). + /// + /// The result is index-aligned with pdfium's per-page text indices, so a text index range + /// `start..=end` maps directly onto a slice range of the returned vector: whitespace is + /// included, characters whose Unicode value is unresolvable are emitted as + /// [`UNKNOWN_TEXT_CHAR`], and characters without usable geometry are emitted as zero-sized + /// boxes rather than skipped. + pub fn extract_char_boxes_from_bytes( + &self, + bytes: &[u8], + page_index: usize, + ) -> Result> { + let guard = self.inner.lock(); + if let Some(pdfium) = &guard.0 { + let doc = pdfium + .load_pdf_from_byte_slice(bytes, None) + .context("Failed to load PDF for char box extraction")?; + + let pages = doc.pages(); + let page_count = pages.len() as usize; + if page_index >= page_count { + anyhow::bail!( + "Requested page index {page_index} out of bounds for document with {page_count} pages" + ); + } + + let page = pages + .get(page_index as u16) + .context("Requested page index out of bounds")?; + + let page_w = page.width().value; + let page_h = page.height().value; + + let text_page = page + .text() + .context("Failed to load page text for char box extraction")?; + + // `PdfPageText::chars()` enumerates every index in `0..FPDFText_CountChars(page)`, which + // is pdfium's own text index order. Iterating segments instead (`seg.chars()`) routes + // through `chars_inside_rect`, which filters characters by the segment rectangle and so + // can drop indices: it is not a complete, index-ordered view of the page. + let chars = text_page.chars(); + let mut boxes = Vec::with_capacity(chars.len()); + + for ch in chars.iter() { + let character = ch.unicode_char().unwrap_or(UNKNOWN_TEXT_CHAR); + + let (x, y, width, height) = + match ch.loose_bounds().or_else(|_| ch.tight_bounds()) { + Ok(bounds) => { + let left = bounds.left().value.min(bounds.right().value); + let right = bounds.left().value.max(bounds.right().value); + let bottom = bounds.bottom().value.min(bounds.top().value); + let top = bounds.bottom().value.max(bounds.top().value); + + normalize_coordinates(left, right, bottom, top, page_w, page_h) + } + // Preserve the slot so index alignment survives geometry-less characters. + Err(_) => (0.0, 0.0, 0.0, 0.0), + }; + + boxes.push(PdfCharBox { + character, + x, + y, + width, + height, + }); + } + + Ok(boxes) + } else { + Ok(Vec::new()) + } + } } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] @@ -527,6 +604,21 @@ pub struct PdfTextSegment { pub height: f32, } +/// Placeholder substituted for a page text index whose Unicode scalar value pdfium cannot +/// resolve. The entry is still emitted so that returned boxes stay index-aligned with +/// pdfium's page text indices, which callers use as selection range coordinates. +const UNKNOWN_TEXT_CHAR: char = '\u{FFFD}'; + +/// A single character on a PDF page together with its box in normalized page coordinates. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PdfCharBox { + pub character: char, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + #[derive(Debug, Clone, Default)] pub struct PdfPageDetails { pub page_index: usize,