Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions crates/pdf-renderer/src/text_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,14 @@ impl PageTextLayout {

/// Builds a normalized inclusive selection between two hits.
pub fn selection_between(&self, anchor: TextHit, focus: TextHit) -> Option<TextSelection> {
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<TextSelection> {
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 })
}

Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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![
Expand Down
3 changes: 3 additions & 0 deletions examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }

Expand All @@ -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 }

Expand All @@ -48,6 +50,7 @@ skia = [
"dep:glutin-winit",
"dep:raw-window-handle",
"dep:winit",
"dep:arboard",
]

# Emscripten/WebGL build (no winit/glutin deps)
Expand Down
171 changes: 169 additions & 2 deletions examples/emscripten.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<PageRecordingCache> = RefCell::new(PageRecordingCache::new(5));
static TEXT_LAYOUT_CACHE: RefCell<HashMap<TextLayoutCacheKey, PageTextLayout>> =
RefCell::new(HashMap::new());
static TEXT_SELECTION_RECTS: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
static SELECTED_TEXT: RefCell<Vec<u8>> = 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<R>(
page_index: usize,
width: i32,
height: i32,
f: impl FnOnce(&PageTextLayout) -> R,
) -> Option<R> {
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]
Expand Down Expand Up @@ -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));
});
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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::<Vec<f32>>()
})
.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.
Expand Down
Loading
Loading