From 22ddd2148444856ed4ff1e099cc1f45b241d2dad Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 00:04:56 +0300 Subject: [PATCH] examples: add WASM text selection Expose text layout hit testing, selection rectangles, and selected text from the Emscripten backend so the web viewer can support text selection. Wire the browser viewer to drag selections across pages, draw highlight overlays, and copy the selected text with Cmd/Ctrl+C. Co-authored-by: Codex --- crates/pdf-renderer/src/text_selection.rs | 38 ++- examples/Cargo.toml | 3 + examples/emscripten.rs | 171 +++++++++- examples/skia.rs | 184 ++++++++++- examples/web/src/safe-pdf-renderer.js | 147 ++++++++- examples/web/src/safe-pdf-viewer.js | 369 +++++++++++++++++++++- xtask/src/main.rs | 4 +- 7 files changed, 880 insertions(+), 36 deletions(-) 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",