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.
This commit is contained in:
yenru0 committed 2026-09-24 04:09:43 +09:00
1 parent ebf342c60b
commit 46d628e9c7
6 files changed
+742 -427

No files matched your search

File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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};
+92
View File
@@ -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<Vec<PdfCharBox>> {
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,