diff --git a/crates/pdf-renderer/src/text_selection.rs b/crates/pdf-renderer/src/text_selection.rs index 3d3e579a..d30ffa1d 100644 --- a/crates/pdf-renderer/src/text_selection.rs +++ b/crates/pdf-renderer/src/text_selection.rs @@ -61,12 +61,14 @@ impl PageTextLayout { /// Builds a normalized inclusive selection between two hits. pub fn selection_between(&self, anchor: TextHit, focus: TextHit) -> Option { - if self.glyphs.is_empty() { - return None; - } + self.selection_from_indices(anchor.index, focus.index) + } + + /// Builds a normalized inclusive selection from glyph indices. + pub fn selection_from_indices(&self, anchor: usize, focus: usize) -> Option { let last_index = self.glyphs.len().checked_sub(1)?; - let start = anchor.index.min(focus.index).min(last_index); - let end = anchor.index.max(focus.index).min(last_index); + let start = anchor.min(focus).min(last_index); + let end = anchor.max(focus).min(last_index); Some(TextSelection { start, end }) } @@ -103,7 +105,10 @@ impl PageTextLayout { .checked_add(1) .map(|end| end.min(self.glyphs.len())) .unwrap_or(self.glyphs.len()); - self.glyphs[start..end_exclusive].iter() + self.glyphs + .iter() + .skip(start) + .take(end_exclusive.saturating_sub(start)) } } @@ -202,6 +207,27 @@ mod tests { assert_eq!(layout.selected_text(selection), "ab"); } + #[test] + fn selection_from_indices_clamps_to_layout() { + let layout = PageTextLayout::new(vec![ + glyph("a", 0.0, 0.0, 10.0, 10.0), + glyph("b", 12.0, 0.0, 20.0, 10.0), + ]); + + let selection = layout + .selection_from_indices(usize::MAX, 0) + .expect("selection should exist"); + + assert_eq!(selection.range(), (0, 1)); + } + + #[test] + fn selection_from_indices_returns_none_for_empty_layout() { + let layout = PageTextLayout::new(Vec::new()); + + assert_eq!(layout.selection_from_indices(0, 0), None); + } + #[test] fn selected_text_inserts_newline_between_lines() { let layout = PageTextLayout::new(vec![ diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 9b74e5ba..b308dc68 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -15,6 +15,7 @@ pdf-tokenizer = { path = "../crates/pdf-tokenizer" } pdf-font = { path = "../crates/pdf-font" } pdf-renderer = { path = "../crates/pdf-renderer" } pdf-canvas = { path = "../crates/pdf-canvas" } +pdf-graphics = { path = "../crates/pdf-graphics" } pdf-graphics-femtovg = { path = "../crates/pdf-graphics-femtovg", optional = true } pdf-graphics-skia = { path = "../crates/pdf-graphics-skia", optional = true, features = ["gl"] } @@ -30,6 +31,7 @@ glutin = { version = "0.32.0", optional = true } glutin-winit = { version = "0.5.0", optional = true } raw-window-handle = { version = "0.6.0", optional = true } winit = { version = "0.30.2", optional = true } +arboard = { version = "3.6.1", optional = true } femtovg = { version = "0.14.0", features = ["wgpu"], optional = true } wgpu = { version = "24.0.3", optional = true } @@ -48,6 +50,7 @@ skia = [ "dep:glutin-winit", "dep:raw-window-handle", "dep:winit", + "dep:arboard", ] # Emscripten/WebGL build (no winit/glutin deps) diff --git a/examples/emscripten.rs b/examples/emscripten.rs index 55796cf4..4c6b5821 100644 --- a/examples/emscripten.rs +++ b/examples/emscripten.rs @@ -4,8 +4,8 @@ use gl_rs as gl; use pdf_document::reader::PdfReader; use pdf_graphics_skia::gpu_state::SkiaGpuState; use pdf_graphics_skia::skia_canvas_backend::SkiaCanvasBackend; -use pdf_renderer::{PageRecordingCache, PdfRenderer, render_page_cached}; -use std::cell::RefCell; +use pdf_renderer::{PageRecordingCache, PageTextLayout, PdfRenderer, render_page_cached}; +use std::{cell::RefCell, collections::HashMap}; // Thread-local storage for the currently loaded PDF renderer. // Using RefCell for interior mutability since WASM is single-threaded. @@ -15,6 +15,70 @@ thread_local! { /// Page recording cache for efficient re-rendering. /// Caches up to 5 pages as resolution-independent drawing commands. static PAGE_CACHE: RefCell = RefCell::new(PageRecordingCache::new(5)); + static TEXT_LAYOUT_CACHE: RefCell> = + RefCell::new(HashMap::new()); + static TEXT_SELECTION_RECTS: RefCell> = const { RefCell::new(Vec::new()) }; + static SELECTED_TEXT: RefCell> = const { RefCell::new(Vec::new()) }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct TextLayoutCacheKey { + page_index: usize, + width: i32, + height: i32, +} + +fn clear_text_selection_state() { + TEXT_LAYOUT_CACHE.with(|cache| { + cache.borrow_mut().clear(); + }); + TEXT_SELECTION_RECTS.with(|rects| { + rects.borrow_mut().clear(); + }); + SELECTED_TEXT.with(|text| { + text.borrow_mut().clear(); + }); +} + +fn with_text_layout( + page_index: usize, + width: i32, + height: i32, + f: impl FnOnce(&PageTextLayout) -> R, +) -> Option { + if width <= 0 || height <= 0 { + return None; + } + + CURRENT_RENDERER.with(|renderer| { + let renderer_ref = renderer.borrow(); + let renderer = renderer_ref.as_ref()?; + if page_index >= renderer.document().page_count() { + return None; + } + + TEXT_LAYOUT_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + let key = TextLayoutCacheKey { + page_index, + width, + height, + }; + + if let std::collections::hash_map::Entry::Vacant(e) = cache.entry(key) { + let layout = match renderer.text_layout(page_index, width as f32, height as f32) { + Ok(layout) => layout, + Err(e) => { + eprintln!("Text layout error: {:?}", e); + return None; + } + }; + e.insert(layout); + } + + cache.get(&key).map(f) + }) + }) } #[macro_export] @@ -66,6 +130,7 @@ pub unsafe extern "C" fn sk_load_pdf(data_ptr: *const u8, data_len: usize) -> i3 PAGE_CACHE.with(|cache| { cache.borrow_mut().clear(); }); + clear_text_selection_state(); CURRENT_RENDERER.with(|renderer| { *renderer.borrow_mut() = Some(PdfRenderer::new(document)); }); @@ -180,6 +245,7 @@ pub extern "C" fn sk_free_pdf() { PAGE_CACHE.with(|cache| { cache.borrow_mut().clear(); }); + clear_text_selection_state(); CURRENT_RENDERER.with(|renderer| { *renderer.borrow_mut() = None; }); @@ -285,6 +351,107 @@ pub extern "C" fn sk_clear_cache() { }); } +/// Clears cached text layouts and temporary selection buffers. +#[unsafe(export_name = "sk_clear_text_layout_cache")] +pub extern "C" fn sk_clear_text_layout_cache() { + clear_text_selection_state(); +} + +/// Returns the number of selectable glyph spans on a rendered page. +#[unsafe(export_name = "sk_get_text_glyph_count")] +pub extern "C" fn sk_get_text_glyph_count(page_index: usize, width: i32, height: i32) -> usize { + with_text_layout(page_index, width, height, |layout| layout.glyphs().len()).unwrap_or(0) +} + +/// Returns the glyph index nearest to a device-space point, or `usize::MAX`. +#[unsafe(export_name = "sk_hit_test_text")] +pub extern "C" fn sk_hit_test_text( + page_index: usize, + width: i32, + height: i32, + x: f32, + y: f32, +) -> usize { + with_text_layout(page_index, width, height, |layout| { + layout + .hit_test(x, y) + .map(|hit| hit.index()) + .unwrap_or(usize::MAX) + }) + .unwrap_or(usize::MAX) +} + +/// Builds selection rectangles for an inclusive glyph-index range. +/// +/// Returns the number of rectangles. Call [`sk_get_text_selection_rects_ptr`] +/// and read `count * 4` f32 values in left/top/right/bottom order. +#[unsafe(export_name = "sk_build_text_selection_rects")] +pub extern "C" fn sk_build_text_selection_rects( + page_index: usize, + width: i32, + height: i32, + start_index: usize, + end_index: usize, +) -> usize { + let rect_values = with_text_layout(page_index, width, height, |layout| { + let Some(selection) = layout.selection_from_indices(start_index, end_index) else { + return Vec::new(); + }; + + layout + .selection_rects(selection) + .into_iter() + .flat_map(|rect| [rect.left, rect.top, rect.right, rect.bottom]) + .collect::>() + }) + .unwrap_or_default(); + + TEXT_SELECTION_RECTS.with(|rects| { + let mut rects = rects.borrow_mut(); + *rects = rect_values; + rects.len() / 4 + }) +} + +/// Returns a pointer to the last built text-selection rectangle buffer. +#[unsafe(export_name = "sk_get_text_selection_rects_ptr")] +pub extern "C" fn sk_get_text_selection_rects_ptr() -> *const f32 { + TEXT_SELECTION_RECTS.with(|rects| rects.borrow().as_ptr()) +} + +/// Builds selected UTF-8 text for an inclusive glyph-index range. +/// +/// Returns the byte length. Call [`sk_get_selected_text_ptr`] and read that +/// many bytes from WASM memory. +#[unsafe(export_name = "sk_build_selected_text")] +pub extern "C" fn sk_build_selected_text( + page_index: usize, + width: i32, + height: i32, + start_index: usize, + end_index: usize, +) -> usize { + let text = with_text_layout(page_index, width, height, |layout| { + let Some(selection) = layout.selection_from_indices(start_index, end_index) else { + return String::new(); + }; + layout.selected_text(selection) + }) + .unwrap_or_default(); + + SELECTED_TEXT.with(|selected| { + let mut selected = selected.borrow_mut(); + *selected = text.into_bytes(); + selected.len() + }) +} + +/// Returns a pointer to the last built selected-text UTF-8 buffer. +#[unsafe(export_name = "sk_get_selected_text_ptr")] +pub extern "C" fn sk_get_selected_text_ptr() -> *const u8 { + SELECTED_TEXT.with(|selected| selected.borrow().as_ptr()) +} + /// Returns the width of the given page in PDF points. /// /// Returns `0.0` if the page index is out of range or the page has no media box. diff --git a/examples/skia.rs b/examples/skia.rs index 412bb063..3af7ddd9 100644 --- a/examples/skia.rs +++ b/examples/skia.rs @@ -9,6 +9,8 @@ use glutin::{ surface::{Surface as GlutinSurface, SurfaceAttributesBuilder, WindowSurface}, }; use glutin_winit::DisplayBuilder; +use pdf_canvas::canvas_backend::{CanvasBackend, Shader}; +use pdf_graphics::{BlendMode, PathFillType, color::Color, pdf_path::PdfPath}; use pdf_graphics_skia::skia_canvas_backend::SkiaCanvasBackend; use raw_window_handle::HasWindowHandle; use skia_safe::{Color as SkiaColor, Surface}; @@ -17,14 +19,17 @@ use winit::keyboard::{Key, ModifiersState, NamedKey}; use winit::{ application::ApplicationHandler, dpi::LogicalSize, - event::{ElementState, KeyEvent, WindowEvent}, + event::{ElementState, KeyEvent, MouseButton, WindowEvent}, event_loop::{ControlFlow, EventLoop}, window::{Window, WindowAttributes}, }; use pdf_document::{document::PdfDocument, reader::PdfReader}; use pdf_graphics_skia::gpu_state::SkiaGpuState; -use pdf_renderer::PdfRenderer; +use pdf_renderer::{ + PdfRenderer, + text_selection::{PageTextLayout, TextHit, TextSelection}, +}; const DEFAULT_INITIAL_WINDOW_SIZE: (u32, u32) = (800, 600); const MAX_INITIAL_WINDOW_WIDTH: f32 = 1200.0; @@ -44,6 +49,8 @@ pub enum AppError { ParsePdf(#[from] pdf_document::error::PdfReaderError), #[error("Failed to render PDF page: {0}")] PdfRendererError(#[from] pdf_renderer::PdfRendererError), + #[error("Failed to draw PDF canvas overlay: {0}")] + PdfCanvasError(#[from] pdf_canvas::error::PdfCanvasError), #[error("Failed to create event loop: {0}")] EventLoop(#[from] winit::error::EventLoopError), #[error("Failed to create window: {0}")] @@ -105,21 +112,37 @@ struct Application { renderer: PdfRenderer, current_page: usize, modifiers: ModifiersState, + text_layout: Option, + selection_anchor: Option, + selection_focus: Option, + selection: Option, + cursor_position: Option<(f32, f32)>, + clipboard: Option, render_error: Option, } impl Application { fn render(&mut self) -> Result<(), AppError> { let size = self.window.inner_size(); + let width = size.width as f32; + let height = size.height as f32; + self.ensure_text_layout(width, height)?; self.surface.canvas().clear(SkiaColor::WHITE); if self.renderer.document().page_count() > 0 { + let selection_rects = self + .text_layout + .as_ref() + .zip(self.selection) + .map(|(layout, selection)| layout.selection_rects(selection)) + .unwrap_or_default(); let mut backend = SkiaCanvasBackend { surface: &mut self.surface, - width: size.width as f32, - height: size.height as f32, + width, + height, }; self.renderer.render(&mut backend, self.current_page)?; + draw_selection_rects(&mut backend, &selection_rects)?; } self.gpu_state.context.flush_and_submit(); @@ -128,29 +151,117 @@ impl Application { Ok(()) } + fn ensure_text_layout(&mut self, width: f32, height: f32) -> Result<(), AppError> { + if self.text_layout.is_some() || self.renderer.document().page_count() == 0 { + return Ok(()); + } + self.text_layout = Some( + self.renderer + .text_layout(self.current_page, width, height)?, + ); + Ok(()) + } + + fn invalidate_text_layout(&mut self) { + self.text_layout = None; + } + + fn clear_selection(&mut self) { + self.selection_anchor = None; + self.selection_focus = None; + self.selection = None; + } + + fn begin_selection(&mut self, x: f32, y: f32) { + let Some(layout) = &self.text_layout else { + self.clear_selection(); + return; + }; + let Some(hit) = layout.hit_test(x, y) else { + self.clear_selection(); + return; + }; + self.selection_anchor = Some(hit); + self.selection_focus = Some(hit); + self.selection = layout.selection_between(hit, hit); + self.window.request_redraw(); + } + + fn update_selection(&mut self, x: f32, y: f32) { + let (Some(layout), Some(anchor)) = (&self.text_layout, self.selection_anchor) else { + return; + }; + let Some(focus) = layout.hit_test(x, y) else { + return; + }; + self.selection_focus = Some(focus); + self.selection = layout.selection_between(anchor, focus); + self.window.request_redraw(); + } + + fn copy_selection(&mut self) { + let (Some(layout), Some(selection)) = (&self.text_layout, self.selection) else { + return; + }; + let text = layout.selected_text(selection); + if text.is_empty() { + return; + } + let Some(clipboard) = self.clipboard.as_mut() else { + eprintln!("Clipboard unavailable"); + return; + }; + if let Err(error) = clipboard.set_text(text) { + eprintln!("Failed to copy selected text: {error}"); + } + } + fn next_page(&mut self) { - let document = self.renderer.document(); - if document.page_count() > 0 { - self.current_page = (self.current_page + 1) % document.page_count(); - println!("Page {}/{}", self.current_page + 1, document.page_count()); + let page_count = self.renderer.document().page_count(); + if page_count > 0 { + self.current_page = (self.current_page + 1) % page_count; + self.clear_selection(); + self.invalidate_text_layout(); + println!("Page {}/{}", self.current_page + 1, page_count); self.window.request_redraw(); } } fn prev_page(&mut self) { - let document = self.renderer.document(); - if document.page_count() > 0 { + let page_count = self.renderer.document().page_count(); + if page_count > 0 { self.current_page = if self.current_page == 0 { - document.page_count() - 1 + page_count - 1 } else { self.current_page - 1 }; - println!("Page {}/{}", self.current_page + 1, document.page_count()); + self.clear_selection(); + self.invalidate_text_layout(); + println!("Page {}/{}", self.current_page + 1, page_count); self.window.request_redraw(); } } } +fn draw_selection_rects( + backend: &mut SkiaCanvasBackend<'_>, + rects: &[pdf_graphics::rect::Rect], +) -> Result<(), AppError> { + let color = Color::from_rgba(0.20, 0.48, 1.0, 0.28); + let shader: Option> = None; + for rect in rects { + let path = PdfPath::from(rect); + backend.fill_path( + &path, + PathFillType::Winding, + color, + &shader, + Some(BlendMode::Normal), + )?; + } + Ok(()) +} + impl ApplicationHandler for Application { fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {} @@ -175,6 +286,8 @@ impl ApplicationHandler for Application { return; } } + self.clear_selection(); + self.invalidate_text_layout(); if let (Some(w), Some(h)) = (NonZeroU32::new(size.width), NonZeroU32::new(size.height)) { @@ -185,6 +298,41 @@ impl ApplicationHandler for Application { WindowEvent::ModifiersChanged(m) => self.modifiers = m.state(), + WindowEvent::CursorMoved { position, .. } => { + let x = position.x as f32; + let y = position.y as f32; + self.cursor_position = Some((x, y)); + if self.selection_anchor.is_some() { + self.update_selection(x, y); + } + } + + WindowEvent::MouseInput { + state, + button: MouseButton::Left, + .. + } => match state { + ElementState::Pressed => { + if let Some((x, y)) = self.cursor_position { + if self.text_layout.is_none() { + let size = self.window.inner_size(); + if let Err(e) = + self.ensure_text_layout(size.width as f32, size.height as f32) + { + self.render_error = Some(e); + event_loop.exit(); + return; + } + } + self.begin_selection(x, y); + } + } + ElementState::Released => { + self.selection_anchor = None; + self.selection_focus = None; + } + }, + WindowEvent::KeyboardInput { event: KeyEvent { @@ -196,6 +344,12 @@ impl ApplicationHandler for Application { } => match &logical_key { Key::Named(NamedKey::ArrowRight) => self.next_page(), Key::Named(NamedKey::ArrowLeft) => self.prev_page(), + Key::Character(c) + if (self.modifiers.super_key() || self.modifiers.control_key()) + && c.eq_ignore_ascii_case("c") => + { + self.copy_selection(); + } Key::Character(c) if self.modifiers.super_key() && c.eq_ignore_ascii_case("q") => { event_loop.exit(); } @@ -281,6 +435,12 @@ fn run(document: PdfDocument) -> Result<(), AppError> { renderer, current_page: 0, modifiers: ModifiersState::default(), + text_layout: None, + selection_anchor: None, + selection_focus: None, + selection: None, + cursor_position: None, + clipboard: arboard::Clipboard::new().ok(), render_error: None, }; diff --git a/examples/web/src/safe-pdf-renderer.js b/examples/web/src/safe-pdf-renderer.js index 4280e52b..b9ec9b50 100644 --- a/examples/web/src/safe-pdf-renderer.js +++ b/examples/web/src/safe-pdf-renderer.js @@ -61,6 +61,13 @@ export class SafePdfRenderer { // ---- Extra WASM function bindings ---- #sk_get_page_width = null; #sk_get_page_height = null; + #sk_clear_text_layout_cache = null; + #sk_get_text_glyph_count = null; + #sk_hit_test_text = null; + #sk_build_text_selection_rects = null; + #sk_get_text_selection_rects_ptr = null; + #sk_build_selected_text = null; + #sk_get_selected_text_ptr = null; /** @type {object|null} Reference to the Emscripten `Module` object. */ #wasmModule = null; @@ -270,6 +277,125 @@ export class SafePdfRenderer { this.#sk_clear_cache(); } + /** + * Clear cached text layouts used for selection and copy. + */ + clearTextLayoutCache() { + this.#assertReady(); + this.#sk_clear_text_layout_cache(); + } + + /** + * Return the number of selectable glyph spans on a rendered page. + * + * @param {number} pageIndex + * @param {number} width + * @param {number} height + * @returns {number} + */ + getTextGlyphCount(pageIndex, width, height) { + this.#assertReady(); + this.#assertPdfLoaded(); + this.#assertPageIndex(pageIndex); + return this.#sk_get_text_glyph_count( + pageIndex, + Math.round(width), + Math.round(height) + ); + } + + /** + * Hit-test a rendered page point against selectable text. + * + * @param {number} pageIndex + * @param {number} width + * @param {number} height + * @param {number} x + * @param {number} y + * @returns {number|null} Glyph index, or `null` when no text is available. + */ + hitTestText(pageIndex, width, height, x, y) { + this.#assertReady(); + this.#assertPdfLoaded(); + this.#assertPageIndex(pageIndex); + + const hit = this.#sk_hit_test_text( + pageIndex, + Math.round(width), + Math.round(height), + x, + y + ); + return hit === 0xFFFFFFFF ? null : hit; + } + + /** + * Return selection highlight rectangles for one rendered page. + * + * @param {number} pageIndex + * @param {number} width + * @param {number} height + * @param {number} startIndex Inclusive glyph index. + * @param {number} endIndex Inclusive glyph index. + * @returns {Array<{left: number, top: number, right: number, bottom: number}>} + */ + getTextSelectionRects(pageIndex, width, height, startIndex, endIndex) { + this.#assertReady(); + this.#assertPdfLoaded(); + this.#assertPageIndex(pageIndex); + + const count = this.#sk_build_text_selection_rects( + pageIndex, + Math.round(width), + Math.round(height), + startIndex, + endIndex + ); + if (count === 0) return []; + + const ptr = this.#sk_get_text_selection_rects_ptr(); + const values = this.#wasmModule.HEAPF32.subarray(ptr / 4, ptr / 4 + count * 4); + const rects = []; + for (let i = 0; i < values.length; i += 4) { + rects.push({ + left: values[i], + top: values[i + 1], + right: values[i + 2], + bottom: values[i + 3], + }); + } + return rects; + } + + /** + * Return selected text for one rendered page. + * + * @param {number} pageIndex + * @param {number} width + * @param {number} height + * @param {number} startIndex Inclusive glyph index. + * @param {number} endIndex Inclusive glyph index. + * @returns {string} + */ + getSelectedText(pageIndex, width, height, startIndex, endIndex) { + this.#assertReady(); + this.#assertPdfLoaded(); + this.#assertPageIndex(pageIndex); + + const length = this.#sk_build_selected_text( + pageIndex, + Math.round(width), + Math.round(height), + startIndex, + endIndex + ); + if (length === 0) return ''; + + const ptr = this.#sk_get_selected_text_ptr(); + const bytes = this.#wasmModule.HEAPU8.slice(ptr, ptr + length); + return new TextDecoder('utf-8').decode(bytes); + } + /** * Return an ordered list of page indices that should be prefetched given * the user's current reading position. @@ -349,6 +475,14 @@ export class SafePdfRenderer { } } + #assertPageIndex(pageIndex) { + if (pageIndex < 0 || pageIndex >= this.#pageCount) { + throw new RangeError( + `Page index ${pageIndex} out of range [0, ${this.#pageCount - 1}]` + ); + } + } + /** * Dynamically load the Emscripten-generated JS file and wait for the * WASM module to finish initialising. @@ -388,11 +522,7 @@ export class SafePdfRenderer { this.#assertReady(); this.#assertPdfLoaded(); - if (pageIndex < 0 || pageIndex >= this.#pageCount) { - throw new RangeError( - `Page index ${pageIndex} out of range [0, ${this.#pageCount - 1}]` - ); - } + this.#assertPageIndex(pageIndex); // Resize the canvas (and reinitialise WebGL) when the target size changes. if (this.#canvas.width !== width || this.#canvas.height !== height) { @@ -463,5 +593,12 @@ export class SafePdfRenderer { this.#sk_get_prefetch_page = M.cwrap('sk_get_prefetch_page', 'number', ['number', 'number']); this.#sk_get_page_width = M.cwrap('sk_get_page_width', 'number', ['number']); this.#sk_get_page_height = M.cwrap('sk_get_page_height', 'number', ['number']); + this.#sk_clear_text_layout_cache = M.cwrap('sk_clear_text_layout_cache', null, []); + this.#sk_get_text_glyph_count = M.cwrap('sk_get_text_glyph_count', 'number', ['number', 'number', 'number']); + this.#sk_hit_test_text = M.cwrap('sk_hit_test_text', 'number', ['number', 'number', 'number', 'number', 'number']); + this.#sk_build_text_selection_rects = M.cwrap('sk_build_text_selection_rects', 'number', ['number', 'number', 'number', 'number', 'number']); + this.#sk_get_text_selection_rects_ptr = M.cwrap('sk_get_text_selection_rects_ptr', 'number', []); + this.#sk_build_selected_text = M.cwrap('sk_build_selected_text', 'number', ['number', 'number', 'number', 'number', 'number']); + this.#sk_get_selected_text_ptr = M.cwrap('sk_get_selected_text_ptr', 'number', []); } } diff --git a/examples/web/src/safe-pdf-viewer.js b/examples/web/src/safe-pdf-viewer.js index 3142ada6..a00929ae 100644 --- a/examples/web/src/safe-pdf-viewer.js +++ b/examples/web/src/safe-pdf-viewer.js @@ -104,6 +104,8 @@ const VIEWER_CSS = /* css */ ` display: flex; align-items: center; justify-content: center; + user-select: none; + cursor: text; } .spdf-page-placeholder { display: flex; @@ -129,6 +131,18 @@ const VIEWER_CSS = /* css */ ` height: 100%; display: block; } +.spdf-selection-layer { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 2; +} +.spdf-selection-rect { + position: absolute; + background: rgba(51, 122, 255, 0.28); + mix-blend-mode: multiply; +} .spdf-loading-overlay { position: absolute; inset: 0; @@ -233,6 +247,12 @@ export class SafePdfViewer extends EventTarget { #prefetchQueue = []; #isPrefetching = false; + // ---- Text selection ---- + #selectionAnchor = null; + #selectionFocus = null; + #isSelecting = false; + #selectedText = ''; + // ---- Scroll ---- #scrollTimeout = null; @@ -248,6 +268,10 @@ export class SafePdfViewer extends EventTarget { #boundHandleScroll; #boundHandleKeydown; #boundHandleResize; + #boundHandlePointerDown; + #boundHandlePointerMove; + #boundHandlePointerUp; + #boundHandleCopy; /** * Create a new SafePdfViewer. @@ -275,6 +299,10 @@ export class SafePdfViewer extends EventTarget { this.#boundHandleScroll = this.#handleScroll.bind(this); this.#boundHandleKeydown = this.#handleKeydown.bind(this); this.#boundHandleResize = this.#handleResize.bind(this); + this.#boundHandlePointerDown = this.#handlePointerDown.bind(this); + this.#boundHandlePointerMove = this.#handlePointerMove.bind(this); + this.#boundHandlePointerUp = this.#handlePointerUp.bind(this); + this.#boundHandleCopy = this.#handleCopy.bind(this); injectCSS(); this.#buildDOM(); @@ -418,6 +446,8 @@ export class SafePdfViewer extends EventTarget { // Flush caches. this.#imageCache.clear(); this.#renderer.clearCache(); + this.#renderer.clearTextLayoutCache(); + this.#clearSelection(); const savedPage = this.#currentPage; @@ -475,6 +505,36 @@ export class SafePdfViewer extends EventTarget { return this.#renderer.getCacheCount(); } + /** + * Return the currently selected text, or an empty string. + * @returns {string} + */ + getSelectedText() { + if (this.#selectedText) return this.#selectedText; + + const ranges = this.#selectedPageRanges(); + if (ranges.length === 0) return ''; + + const parts = []; + for (const range of ranges) { + const { width, height } = this.#scaledPageSizeForPage(range.pageIndex); + const text = this.#renderer.getSelectedText( + range.pageIndex, + width, + height, + range.startIndex, + range.endIndex + ); + if (text) parts.push(text); + } + return parts.join('\n'); + } + + /** Clear the current text selection. */ + clearSelection() { + this.#clearSelection(); + } + /** * Access the underlying {@link SafePdfRenderer} for advanced usage. * @returns {SafePdfRenderer} @@ -488,6 +548,7 @@ export class SafePdfViewer extends EventTarget { */ destroy() { this.#detachEventListeners(); + this.#clearSelection(); this.#renderer.destroy(); this.#container.innerHTML = ''; this.#imageCache.clear(); @@ -504,6 +565,7 @@ export class SafePdfViewer extends EventTarget { // Scroll container this.#scrollContainer = document.createElement('div'); this.#scrollContainer.className = 'spdf-scroll-container'; + this.#scrollContainer.tabIndex = 0; // Loading overlay this.#loadingOverlay = document.createElement('div'); @@ -545,6 +607,10 @@ export class SafePdfViewer extends EventTarget { placeholder.textContent = `Page ${i + 1}`; wrapper.appendChild(placeholder); + const selectionLayer = document.createElement('div'); + selectionLayer.className = 'spdf-selection-layer'; + wrapper.appendChild(selectionLayer); + const label = document.createElement('div'); label.className = 'spdf-page-number'; label.textContent = `Page ${i + 1}`; @@ -567,17 +633,45 @@ export class SafePdfViewer extends EventTarget { { passive: true } ); - if (this.#options.keyboardNav !== false) { - document.addEventListener('keydown', this.#boundHandleKeydown); - } - + document.addEventListener('keydown', this.#boundHandleKeydown, true); window.addEventListener('resize', this.#boundHandleResize); + this.#scrollContent.addEventListener( + 'pointerdown', + this.#boundHandlePointerDown + ); + this.#scrollContainer.addEventListener( + 'pointermove', + this.#boundHandlePointerMove + ); + this.#scrollContainer.addEventListener('pointerup', this.#boundHandlePointerUp); + this.#scrollContainer.addEventListener( + 'pointercancel', + this.#boundHandlePointerUp + ); + document.addEventListener('copy', this.#boundHandleCopy, true); } #detachEventListeners() { this.#scrollContainer?.removeEventListener('scroll', this.#boundHandleScroll); - document.removeEventListener('keydown', this.#boundHandleKeydown); + document.removeEventListener('keydown', this.#boundHandleKeydown, true); window.removeEventListener('resize', this.#boundHandleResize); + this.#scrollContent?.removeEventListener( + 'pointerdown', + this.#boundHandlePointerDown + ); + this.#scrollContainer?.removeEventListener( + 'pointermove', + this.#boundHandlePointerMove + ); + this.#scrollContainer?.removeEventListener( + 'pointerup', + this.#boundHandlePointerUp + ); + this.#scrollContainer?.removeEventListener( + 'pointercancel', + this.#boundHandlePointerUp + ); + document.removeEventListener('copy', this.#boundHandleCopy, true); } // ================================================================== @@ -590,6 +684,7 @@ export class SafePdfViewer extends EventTarget { */ #loadBuffer(buffer) { this.#imageCache.clear(); + this.#clearSelection(); const { pageCount } = this.#renderer.loadPdf(buffer); this.#pageCount = pageCount; this.#currentPage = 0; @@ -775,6 +870,160 @@ export class SafePdfViewer extends EventTarget { // Kick off background prefetch. this.#schedulePrefetch(this.#currentPage); + this.#renderSelectionHighlights(); + } + + // ================================================================== + // Text Selection + // ================================================================== + + #clearSelection() { + this.#selectionAnchor = null; + this.#selectionFocus = null; + this.#isSelecting = false; + this.#selectedText = ''; + this.#clearSelectionLayers(); + } + + #clearSelectionLayers() { + for (const wrapper of this.#scrollContent?.children ?? []) { + const layer = wrapper.querySelector?.('.spdf-selection-layer'); + if (layer) layer.innerHTML = ''; + } + } + + #selectedPageRanges() { + if (!this.#selectionAnchor || !this.#selectionFocus) return []; + + const anchorBeforeFocus = + this.#compareSelectionPoints(this.#selectionAnchor, this.#selectionFocus) <= 0; + const start = anchorBeforeFocus ? this.#selectionAnchor : this.#selectionFocus; + const end = anchorBeforeFocus ? this.#selectionFocus : this.#selectionAnchor; + const ranges = []; + + for (let pageIndex = start.pageIndex; pageIndex <= end.pageIndex; pageIndex++) { + const { width, height } = this.#scaledPageSizeForPage(pageIndex); + const glyphCount = this.#renderer.getTextGlyphCount(pageIndex, width, height); + if (glyphCount === 0) continue; + + const startIndex = pageIndex === start.pageIndex ? start.glyphIndex : 0; + const endIndex = + pageIndex === end.pageIndex ? end.glyphIndex : glyphCount - 1; + ranges.push({ pageIndex, startIndex, endIndex }); + } + + return ranges; + } + + #renderSelectionHighlights() { + this.#clearSelectionLayers(); + + for (const range of this.#selectedPageRanges()) { + const wrapper = this.#scrollContent.children[range.pageIndex]; + if (!wrapper) continue; + + const layer = wrapper.querySelector('.spdf-selection-layer'); + if (!layer) continue; + + const { width, height } = this.#scaledPageSizeForPage(range.pageIndex); + const rects = this.#renderer.getTextSelectionRects( + range.pageIndex, + width, + height, + range.startIndex, + range.endIndex + ); + + for (const rect of rects) { + const left = Math.min(rect.left, rect.right); + const top = Math.min(rect.top, rect.bottom); + const rectWidth = Math.abs(rect.right - rect.left); + const rectHeight = Math.abs(rect.bottom - rect.top); + if (rectWidth <= 0 || rectHeight <= 0) continue; + + const el = document.createElement('div'); + el.className = 'spdf-selection-rect'; + el.style.left = `${left}px`; + el.style.top = `${top}px`; + el.style.width = `${rectWidth}px`; + el.style.height = `${rectHeight}px`; + layer.appendChild(el); + } + } + } + + #refreshSelectedText() { + const ranges = this.#selectedPageRanges(); + if (ranges.length === 0) { + this.#selectedText = ''; + return; + } + + const parts = []; + for (const range of ranges) { + const { width, height } = this.#scaledPageSizeForPage(range.pageIndex); + const text = this.#renderer.getSelectedText( + range.pageIndex, + width, + height, + range.startIndex, + range.endIndex + ); + if (text) parts.push(text); + } + this.#selectedText = parts.join('\n'); + } + + #compareSelectionPoints(a, b) { + if (a.pageIndex !== b.pageIndex) { + return a.pageIndex - b.pageIndex; + } + return a.glyphIndex - b.glyphIndex; + } + + #selectionHitFromEvent(e) { + const pagePoint = this.#pagePointFromClientPoint(e.clientX, e.clientY); + if (!pagePoint) return null; + + const { width, height } = this.#scaledPageSizeForPage(pagePoint.pageIndex); + const glyphIndex = this.#renderer.hitTestText( + pagePoint.pageIndex, + width, + height, + pagePoint.x, + pagePoint.y + ); + if (glyphIndex === null) return null; + + return { pageIndex: pagePoint.pageIndex, glyphIndex }; + } + + #pagePointFromClientPoint(clientX, clientY) { + let nearest = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const wrapper of this.#scrollContent.children) { + if (!wrapper.classList?.contains('spdf-page-wrapper')) continue; + const pageIndex = Number(wrapper.dataset.pageIndex); + if (!Number.isInteger(pageIndex)) continue; + + const rect = wrapper.getBoundingClientRect(); + const x = Math.min(Math.max(clientX - rect.left, 0), rect.width); + const y = Math.min(Math.max(clientY - rect.top, 0), rect.height); + + if (clientY >= rect.top && clientY <= rect.bottom) { + return { pageIndex, x, y }; + } + + const distance = + clientY < rect.top ? rect.top - clientY : clientY - rect.bottom; + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = { pageIndex, x, y }; + } + } + + return nearest; } // ================================================================== @@ -835,6 +1084,58 @@ export class SafePdfViewer extends EventTarget { // Event Handlers // ================================================================== + #handlePointerDown(e) { + if (this.#pageCount === 0 || e.button !== 0) return; + + const hit = this.#selectionHitFromEvent(e); + if (!hit) { + this.#clearSelection(); + return; + } + + e.preventDefault(); + this.#scrollContainer.focus({ preventScroll: true }); + this.#isSelecting = true; + this.#selectionAnchor = hit; + this.#selectionFocus = hit; + this.#scrollContainer.setPointerCapture?.(e.pointerId); + this.#refreshSelectedText(); + this.#renderSelectionHighlights(); + } + + #handlePointerMove(e) { + if (!this.#isSelecting) return; + + const hit = this.#selectionHitFromEvent(e); + if (!hit) return; + + e.preventDefault(); + this.#selectionFocus = hit; + this.#refreshSelectedText(); + this.#renderSelectionHighlights(); + } + + #handlePointerUp(e) { + if (!this.#isSelecting) return; + + this.#isSelecting = false; + this.#scrollContainer.releasePointerCapture?.(e.pointerId); + } + + #handleCopy(e) { + if (this.#isEditableEventTarget(e.target)) return; + + const text = this.#selectedText || this.getSelectedText(); + if (!text) return; + + e.preventDefault(); + if (e.clipboardData) { + e.clipboardData.setData('text/plain', text); + } else { + this.#writeTextToClipboard(text); + } + } + #handleScroll() { if (this.#scrollTimeout) clearTimeout(this.#scrollTimeout); @@ -853,10 +1154,19 @@ export class SafePdfViewer extends EventTarget { // Guard: e.target can be null or a non-HTMLElement for document-level // key events. Also skip text-input elements to avoid hijacking typing. - if (e.target instanceof HTMLElement) { - const tag = e.target.tagName; - if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return; - if (e.target.isContentEditable) return; + if (this.#isEditableEventTarget(e.target)) return; + + if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'c') { + const text = this.#selectedText || this.getSelectedText(); + if (text) { + e.preventDefault(); + this.#writeTextToClipboard(text); + } + return; + } + + if (this.#options.keyboardNav === false) { + return; } switch (e.key) { @@ -895,6 +1205,47 @@ export class SafePdfViewer extends EventTarget { this.#loadingOverlay?.classList.toggle('spdf-hidden', !show); } + #isEditableEventTarget(target) { + if (!(target instanceof HTMLElement)) return false; + + const tag = target.tagName; + return ( + tag === 'INPUT' || + tag === 'SELECT' || + tag === 'TEXTAREA' || + target.isContentEditable + ); + } + + #writeTextToClipboard(text) { + const copied = this.#copyTextWithTextarea(text); + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).catch(() => {}); + } + + return copied; + } + + #copyTextWithTextarea(text) { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + textarea.remove(); + } + } + /** Update current page and dispatch event. */ #setCurrentPage(index) { this.#currentPage = index; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0b93f12a..4777dc0c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -155,8 +155,8 @@ fn build_emscripten( // ALLOW_MEMORY_GROWTH – duplicated from EMCC_CFLAGS to ensure it // reaches the linker; the heap can grow beyond INITIAL_MEMORY. let rustflags = [ - "-C link-args=-sEXPORTED_FUNCTIONS=['_sk_load_pdf','_sk_get_prefetch_count','_sk_get_prefetch_page','_sk_get_page_count','_sk_render_page','_sk_free_pdf','_sk_reset_gpu','_sk_is_page_cached','_sk_get_cache_count','_sk_clear_cache','_sk_get_page_width','_sk_get_page_height','_malloc','_free','_main']", - "-C link-args=-sEXPORTED_RUNTIME_METHODS=['cwrap','HEAPU8']", + "-C link-args=-sEXPORTED_FUNCTIONS=['_sk_load_pdf','_sk_get_prefetch_count','_sk_get_prefetch_page','_sk_get_page_count','_sk_render_page','_sk_free_pdf','_sk_reset_gpu','_sk_is_page_cached','_sk_get_cache_count','_sk_clear_cache','_sk_get_page_width','_sk_get_page_height','_sk_clear_text_layout_cache','_sk_get_text_glyph_count','_sk_hit_test_text','_sk_build_text_selection_rects','_sk_get_text_selection_rects_ptr','_sk_build_selected_text','_sk_get_selected_text_ptr','_malloc','_free','_main']", + "-C link-args=-sEXPORTED_RUNTIME_METHODS=['cwrap','HEAPU8','HEAPF32']", "-C link-args=-sSTANDALONE_WASM=0", "-C link-args=-sINITIAL_MEMORY=134217728", "-C link-args=-sSTACK_SIZE=2097152",