diff --git a/crates/pdf-canvas/src/lib.rs b/crates/pdf-canvas/src/lib.rs index 424b0402..f4648243 100644 --- a/crates/pdf-canvas/src/lib.rs +++ b/crates/pdf-canvas/src/lib.rs @@ -19,6 +19,7 @@ pub mod pdf_canvas; pub mod recording_canvas; mod shading; pub mod stroke_style; +pub mod text; mod text_renderer; mod text_state; mod truetype_font_renderer; diff --git a/crates/pdf-canvas/src/pdf_canvas.rs b/crates/pdf-canvas/src/pdf_canvas.rs index 9fd70e57..16b39d97 100644 --- a/crates/pdf-canvas/src/pdf_canvas.rs +++ b/crates/pdf-canvas/src/pdf_canvas.rs @@ -8,6 +8,7 @@ use crate::{ pdf_path_pen::PdfPathPen, recording_canvas::RecordingCanvas, stroke_style::StrokeStyle, + text::{TextGlyph, TextSink, glyph_bounds, glyph_text}, text_state::TextState, }; use pdf_content_stream::ContentStream; @@ -43,6 +44,8 @@ pub struct PdfCanvas<'a, B: CanvasBackend> { pub(crate) canvas_stack: Vec>, /// Content-stream IDs currently being rendered on this canvas stack. pub(crate) active_content_stream_ids: HashSet, + /// Optional sink for extracted text glyph positions. + pub(crate) text_sink: Option<&'a mut dyn TextSink>, } impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { @@ -115,9 +118,22 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { page, canvas_stack, active_content_stream_ids: HashSet::new(), + text_sink: None, }) } + /// Creates a new `PdfCanvas` and emits rendered text spans into `text_sink`. + pub fn new_with_text_sink( + backend: &'a mut B, + page: &'a PdfPage, + bb: Option<&Rect>, + text_sink: &'a mut dyn TextSink, + ) -> Result { + let mut canvas = Self::new(backend, page, bb)?; + canvas.text_sink = Some(text_sink); + Ok(canvas) + } + /// Returns whether a form or pattern bbox is safe to materialize as an offscreen recording. /// /// PDFs in the wild sometimes contain malformed `/BBox` values for Form XObjects or tiling @@ -212,6 +228,7 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { page: self.page, canvas_stack, active_content_stream_ids: self.active_content_stream_ids.clone(), + text_sink: None, }; // Render the form's content stream into the mask canvas. @@ -791,6 +808,33 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> { TextRenderingMode::Clip => self.add_to_text_clip(path), } } + + pub(crate) fn record_text_glyph( + &mut self, + char_code: u16, + text_state_before_advance: &TextState<'a>, + ctm: &Transform, + ) -> Result<(), PdfCanvasError> { + if self.text_sink.is_none() { + return Ok(()); + } + + let text_state_after_advance = &self.current_state()?.text_state; + let text = glyph_text(text_state_before_advance.font, char_code); + if text.is_empty() { + return Ok(()); + } + + let bounds = glyph_bounds(text_state_before_advance, ctm, text_state_after_advance); + if bounds.is_valid() { + let Some(text_sink) = self.text_sink.as_deref_mut() else { + return Ok(()); + }; + text_sink.push_glyph(TextGlyph { text, bounds }); + } + + Ok(()) + } } #[cfg(test)] diff --git a/crates/pdf-canvas/src/text.rs b/crates/pdf-canvas/src/text.rs new file mode 100644 index 00000000..556faba8 --- /dev/null +++ b/crates/pdf-canvas/src/text.rs @@ -0,0 +1,148 @@ +use pdf_font::font::Font; +use pdf_graphics::{rect::Rect, transform::Transform}; + +use crate::text_state::TextState; + +/// One extracted text glyph or character span in rendered device coordinates. +#[derive(Debug, Clone, PartialEq)] +pub struct TextGlyph { + /// Unicode text represented by this glyph span. + pub text: String, + /// Axis-aligned device-space bounds for hit testing and highlighting. + pub bounds: Rect, +} + +/// Receives text spans while a page content stream is rendered. +pub trait TextSink { + /// Records one extracted text span. + fn push_glyph(&mut self, glyph: TextGlyph); +} + +/// Collects text spans emitted by [`PdfCanvas`](crate::pdf_canvas::PdfCanvas). +#[derive(Default)] +pub struct TextCollector { + glyphs: Vec, +} + +impl TextCollector { + /// Creates an empty text collector. + pub fn new() -> Self { + Self::default() + } + + /// Returns the collected glyph spans. + pub fn glyphs(&self) -> &[TextGlyph] { + &self.glyphs + } + + /// Consumes the collector and returns its glyph spans. + pub fn into_glyphs(self) -> Vec { + self.glyphs + } +} + +impl TextSink for TextCollector { + fn push_glyph(&mut self, glyph: TextGlyph) { + self.glyphs.push(glyph); + } +} + +pub(crate) fn glyph_text(font: Option<&Font>, char_code: u16) -> String { + font.map(|current_font| current_font.chars_to_unicode(char_code)) + .filter(|chars| !chars.is_empty()) + .map(|chars| chars.iter().collect()) + .unwrap_or_else(|| { + char::from_u32(u32::from(char_code)) + .unwrap_or('\u{fffd}') + .to_string() + }) +} + +pub(crate) fn glyph_bounds( + text_state_before_advance: &TextState<'_>, + ctm: &Transform, + text_state_after_advance: &TextState<'_>, +) -> Rect { + let mut before = text_state_before_advance.matrix; + before.concat(ctm); + + let mut after = text_state_after_advance.matrix; + after.concat(ctm); + + let (start_x, start_y) = before.transform_point(0.0, 0.0); + let (end_x, end_y) = after.transform_point(0.0, 0.0); + let font_size = text_state_before_advance.font_size.abs().max(1.0); + let local_rect = Rect { + left: 0.0, + top: font_size * 0.8, + right: font_size * 0.5, + bottom: -font_size * 0.25, + }; + let mut rect = before.map_rect(&local_rect); + + rect.left = rect.left.min(start_x).min(end_x); + rect.right = rect.right.max(start_x).max(end_x); + rect.top = rect.top.min(start_y).min(end_y); + rect.bottom = rect.bottom.max(start_y).max(end_y); + + rect.normalized() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::text_state::TextState; + + #[test] + fn glyph_bounds_cover_advanced_text_position() { + let before = TextState { + font_size: 12.0, + ..Default::default() + }; + let mut after = before.clone(); + after.matrix.post_translate(24.0, 0.0); + + let bounds = glyph_bounds(&before, &Transform::identity(), &after); + + assert!(bounds.left <= 0.0); + assert!(bounds.right >= 24.0); + assert!(bounds.top < bounds.bottom); + } + + #[test] + fn glyph_bounds_apply_ctm() { + let before = TextState { + font_size: 10.0, + ..Default::default() + }; + let mut after = before.clone(); + after.matrix.post_translate(10.0, 0.0); + let ctm = Transform::from_translate(5.0, 7.0); + + let bounds = glyph_bounds(&before, &ctm, &after); + + assert!(bounds.left <= 5.0); + assert!(bounds.right >= 15.0); + assert!(bounds.top <= 7.0); + } + + #[test] + fn glyph_bounds_respect_flipped_canvas_ctm() { + let mut before = TextState { + font_size: 20.0, + ..Default::default() + }; + before.matrix.post_translate(30.0, 40.0); + let mut after = before.clone(); + after.matrix.post_translate(10.0, 0.0); + let ctm = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, 100.0); + + let bounds = glyph_bounds(&before, &ctm, &after); + + let (_baseline_x, baseline_y) = before.matrix.transform_point(0.0, 0.0); + let baseline_y = 100.0 - baseline_y; + assert!(bounds.top < baseline_y); + assert!(bounds.bottom > baseline_y); + assert!(bounds.bottom - baseline_y < baseline_y - bounds.top); + } +} diff --git a/crates/pdf-canvas/src/truetype_font_renderer.rs b/crates/pdf-canvas/src/truetype_font_renderer.rs index 77bb7a09..29efdcd7 100644 --- a/crates/pdf-canvas/src/truetype_font_renderer.rs +++ b/crates/pdf-canvas/src/truetype_font_renderer.rs @@ -99,6 +99,8 @@ impl TextRenderer for TrueTypeFontRenderer<'_, '_, B> { ) -> Result<(), crate::error::PdfCanvasError> { for char_code in text { let state = self.canvas.current_state()?; + let text_state_before_advance = state.text_state.clone(); + let ctm = state.transform; let glyph_matrix_for_char = state .text_state .compose_glyph_matrix(self.glyph_base_transform, &state.transform); @@ -142,6 +144,8 @@ impl TextRenderer for TrueTypeFontRenderer<'_, '_, B> { resolved_glyph_id, self.units_per_em, )?; + self.canvas + .record_text_glyph(char_code, &text_state_before_advance, &ctm)?; } Ok(()) } diff --git a/crates/pdf-canvas/src/type1_font_renderer.rs b/crates/pdf-canvas/src/type1_font_renderer.rs index 2857356b..64d8062f 100644 --- a/crates/pdf-canvas/src/type1_font_renderer.rs +++ b/crates/pdf-canvas/src/type1_font_renderer.rs @@ -351,13 +351,6 @@ impl<'a, 'b, B: CanvasBackend> Type1FontRenderer<'a, 'b, B> { }) } - fn glyph_matrix(&self) -> Result { - let state = self.canvas.current_state()?; - Ok(state - .text_state - .compose_glyph_matrix(self.glyph_base_transform, &state.transform)) - } - fn prepare_glyph(&self, char_code: u16) -> Result, PdfCanvasError> { self.font .prepare_glyph(&*self.canvas, self.is_cid, char_code) @@ -381,10 +374,17 @@ impl<'a, 'b, B: CanvasBackend> Type1FontRenderer<'a, 'b, B> { } fn render_char(&mut self, char_code: u16) -> Result<(), PdfCanvasError> { - let glyph_matrix = self.glyph_matrix()?; + let state = self.canvas.current_state()?; + let text_state_before_advance = state.text_state.clone(); + let ctm = state.transform; + let glyph_matrix = state + .text_state + .compose_glyph_matrix(self.glyph_base_transform, &state.transform); let mut glyph = self.prepare_glyph(char_code)?; self.draw_glyph(&mut glyph, &glyph_matrix)?; - self.advance_text_position(char_code, &glyph) + self.advance_text_position(char_code, &glyph)?; + self.canvas + .record_text_glyph(char_code, &text_state_before_advance, &ctm) } } diff --git a/crates/pdf-canvas/src/type3_font_renderer.rs b/crates/pdf-canvas/src/type3_font_renderer.rs index 921acb45..baca1d67 100644 --- a/crates/pdf-canvas/src/type3_font_renderer.rs +++ b/crates/pdf-canvas/src/type3_font_renderer.rs @@ -42,6 +42,8 @@ impl TextRenderer for Type3FontRenderer<'_, '_, B> { ) -> Result<(), crate::error::PdfCanvasError> { for char_code_byte in iter { let state = self.canvas.current_state()?; + let text_state_before_advance = state.text_state.clone(); + let ctm = state.transform; // Compute a relative glyph matrix (Tm × S × FontMatrix) without the CTM. // render_content_stream will post-concatenate this onto the current CTM, @@ -109,6 +111,8 @@ impl TextRenderer for Type3FontRenderer<'_, '_, B> { let glyph_width_y = (y1 - y0) * text_state.font_size; text_state.advance_text_cursor(char_code_byte, glyph_width_x, glyph_width_y); + self.canvas + .record_text_glyph(char_code_byte, &text_state_before_advance, &ctm)?; } } diff --git a/crates/pdf-renderer/Cargo.toml b/crates/pdf-renderer/Cargo.toml index 30d1bc3e..280e6328 100644 --- a/crates/pdf-renderer/Cargo.toml +++ b/crates/pdf-renderer/Cargo.toml @@ -8,4 +8,5 @@ pdf-annotations = { path = "../pdf-annotations" } pdf-content-stream = { path = "../pdf-content-stream" } pdf-document = { path = "../pdf-document" } pdf-canvas = { path = "../pdf-canvas" } +pdf-graphics = { path = "../pdf-graphics" } thiserror = "2.0.12" diff --git a/crates/pdf-renderer/src/lib.rs b/crates/pdf-renderer/src/lib.rs index 196ae2cb..c447ba58 100644 --- a/crates/pdf-renderer/src/lib.rs +++ b/crates/pdf-renderer/src/lib.rs @@ -1,13 +1,23 @@ use pdf_annotations::{AnnotationRenderError, AnnotationRenderer}; use pdf_canvas::{ - canvas_backend::CanvasBackend, pdf_canvas::PdfCanvas, recording_canvas::RecordingCanvas, + canvas_backend::{CanvasBackend, Image, Shader}, + error::PdfCanvasError, + pdf_canvas::PdfCanvas, + recording_canvas::RecordingCanvas, + stroke_style::StrokeStyle, }; use pdf_document::document::PdfDocument; +use pdf_graphics::{ + BlendMode, MaskMode, PathFillType, color::Color, pdf_path::PdfPath, rect::Rect, + transform::Transform, +}; use thiserror::Error; pub mod page_cache; +pub mod text_selection; pub use page_cache::PageRecordingCache; +pub use text_selection::PageTextLayout; /// Errors that can occur while rendering a PDF document onto a canvas backend. #[derive(Debug, Error)] @@ -110,6 +120,33 @@ impl PdfRenderer { Ok(recording) } + /// Extracts selectable page text for a specific rendered page size. + pub fn text_layout( + &self, + page_index: usize, + width: f32, + height: f32, + ) -> Result { + let page = self.page(page_index)?; + let mut backend = NoopCanvasBackend { width, height }; + let mut collector = pdf_canvas::text::TextCollector::new(); + { + let mut canvas = + PdfCanvas::new_with_text_sink(&mut backend, page, None, &mut collector)?; + if let Some(cs) = &page.contents { + canvas.render_content_stream(cs, None, None, page.resources.as_ref(), None)?; + } + } + + Ok(PageTextLayout::new( + collector + .into_glyphs() + .into_iter() + .map(Into::into) + .collect(), + )) + } + fn page(&self, page_index: usize) -> Result<&pdf_document::page::PdfPage, PdfRendererError> { let Some(page) = self.document.pages.get(page_index) else { return Err(PdfRendererError::PageNotFound(page_index)); @@ -118,6 +155,88 @@ impl PdfRenderer { } } +struct NoopCanvasBackend { + width: f32, + height: f32, +} + +impl CanvasBackend for NoopCanvasBackend { + fn fill_path( + &mut self, + _path: &PdfPath, + _fill_type: PathFillType, + _color: Color, + _shader: &Option, + _blend_mode: Option, + ) -> Result<(), PdfCanvasError> { + Ok(()) + } + + fn stroke_path( + &mut self, + _path: &PdfPath, + _color: Color, + _line_width: f32, + _stroke_style: &StrokeStyle, + _shader: &Option, + _blend_mode: Option, + ) -> Result<(), PdfCanvasError> { + Ok(()) + } + + fn set_clip_region( + &mut self, + _path: &PdfPath, + _mode: PathFillType, + ) -> Result<(), PdfCanvasError> { + Ok(()) + } + + fn width(&self) -> f32 { + self.width + } + + fn height(&self) -> f32 { + self.height + } + + fn save(&mut self) -> Result<(), PdfCanvasError> { + Ok(()) + } + + fn restore(&mut self) -> Result<(), PdfCanvasError> { + Ok(()) + } + + fn draw_image_rect( + &mut self, + _image: &Image<'_>, + _blend_mode: Option, + _dest_rect: Rect, + _image_rotation: Option, + ) -> Result<(), PdfCanvasError> { + Ok(()) + } + + fn begin_mask_layer( + &mut self, + _mask: &std::sync::Arc, + _transform: &Transform, + _mask_mode: MaskMode, + ) -> Result<(), PdfCanvasError> { + Ok(()) + } + + fn end_mask_layer( + &mut self, + _mask: &std::sync::Arc, + _transform: &Transform, + _mask_mode: MaskMode, + ) -> Result<(), PdfCanvasError> { + Ok(()) + } +} + /// Renders a cached [`RecordingCanvas`] to a backend, or renders the page /// directly if not cached. /// diff --git a/crates/pdf-renderer/src/text_selection.rs b/crates/pdf-renderer/src/text_selection.rs new file mode 100644 index 00000000..3d3e579a --- /dev/null +++ b/crates/pdf-renderer/src/text_selection.rs @@ -0,0 +1,217 @@ +use pdf_graphics::rect::Rect; + +/// Ordered text layout for one rendered page size. +#[derive(Debug, Clone, Default)] +pub struct PageTextLayout { + glyphs: Vec, +} + +/// One selectable text span in device coordinates. +#[derive(Debug, Clone, PartialEq)] +pub struct TextGlyph { + /// Unicode text represented by this span. + pub text: String, + /// Axis-aligned device-space bounds. + pub bounds: Rect, +} + +/// A hit-testable text position. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextHit { + index: usize, +} + +/// A selected inclusive glyph range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextSelection { + start: usize, + end: usize, +} + +impl PageTextLayout { + /// Creates a page text layout from ordered glyph spans. + pub fn new(glyphs: Vec) -> Self { + Self { glyphs } + } + + /// Returns all glyph spans in content-stream order. + pub fn glyphs(&self) -> &[TextGlyph] { + &self.glyphs + } + + /// Finds the nearest glyph hit for a device-space point. + pub fn hit_test(&self, x: f32, y: f32) -> Option { + let direct = self.glyphs.iter().enumerate().find_map(|(index, glyph)| { + contains_point(&glyph.bounds, x, y).then_some(TextHit { index }) + }); + if direct.is_some() { + return direct; + } + + self.glyphs + .iter() + .enumerate() + .filter_map(|(index, glyph)| { + let distance = distance_to_rect(&glyph.bounds, x, y); + distance.is_finite().then_some((index, distance)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(index, _)| TextHit { index }) + } + + /// 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; + } + 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); + Some(TextSelection { start, end }) + } + + /// Returns highlight rectangles for a selection. + pub fn selection_rects(&self, selection: TextSelection) -> Vec { + self.selected_glyphs(selection) + .filter_map(|glyph| glyph.bounds.is_valid().then_some(glyph.bounds)) + .collect() + } + + /// Returns copied text for a selection. + pub fn selected_text(&self, selection: TextSelection) -> String { + let mut result = String::new(); + let mut previous: Option<&TextGlyph> = None; + + for glyph in self.selected_glyphs(selection) { + if let Some(prev) = previous + && is_new_line(prev, glyph) + && !result.ends_with('\n') + { + result.push('\n'); + } + result.push_str(&glyph.text); + previous = Some(glyph); + } + + result + } + + fn selected_glyphs(&self, selection: TextSelection) -> impl Iterator { + let start = selection.start.min(self.glyphs.len()); + let end_exclusive = selection + .end + .checked_add(1) + .map(|end| end.min(self.glyphs.len())) + .unwrap_or(self.glyphs.len()); + self.glyphs[start..end_exclusive].iter() + } +} + +impl TextHit { + /// Returns the underlying glyph index in content-stream order. + pub fn index(self) -> usize { + self.index + } +} + +impl TextSelection { + /// Returns the inclusive glyph-index range. + pub fn range(self) -> (usize, usize) { + (self.start, self.end) + } +} + +impl From for TextGlyph { + fn from(value: pdf_canvas::text::TextGlyph) -> Self { + Self { + text: value.text, + bounds: value.bounds, + } + } +} + +fn contains_point(rect: &Rect, x: f32, y: f32) -> bool { + let rect = rect.normalized(); + x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom +} + +fn distance_to_rect(rect: &Rect, x: f32, y: f32) -> f32 { + let rect = rect.normalized(); + let dx = if x < rect.left { + rect.left - x + } else if x > rect.right { + x - rect.right + } else { + 0.0 + }; + let dy = if y < rect.top { + rect.top - y + } else if y > rect.bottom { + y - rect.bottom + } else { + 0.0 + }; + dx.hypot(dy) +} + +fn is_new_line(previous: &TextGlyph, current: &TextGlyph) -> bool { + let previous_height = previous.bounds.height().abs().max(1.0); + let current_height = current.bounds.height().abs().max(1.0); + let threshold = previous_height.max(current_height) * 0.6; + current.bounds.top > previous.bounds.bottom + threshold +} + +#[cfg(test)] +mod tests { + use super::*; + + fn glyph(text: &str, left: f32, top: f32, right: f32, bottom: f32) -> TextGlyph { + TextGlyph { + text: text.to_string(), + bounds: Rect { + left, + top, + right, + bottom, + }, + } + } + + #[test] + fn hit_test_returns_containing_glyph() { + 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), + ]); + + assert_eq!(layout.hit_test(13.0, 5.0).map(TextHit::index), Some(1)); + } + + #[test] + fn selection_between_normalizes_reverse_drag() { + 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_between(TextHit { index: 1 }, TextHit { index: 0 }) + .expect("selection should exist"); + + assert_eq!(selection.range(), (0, 1)); + assert_eq!(layout.selected_text(selection), "ab"); + } + + #[test] + fn selected_text_inserts_newline_between_lines() { + let layout = PageTextLayout::new(vec![ + glyph("a", 0.0, 0.0, 10.0, 10.0), + glyph("b", 0.0, 24.0, 10.0, 34.0), + ]); + let selection = layout + .selection_between(TextHit { index: 0 }, TextHit { index: 1 }) + .expect("selection should exist"); + + assert_eq!(layout.selected_text(selection), "a\nb"); + } +}