Skip to main content

pdfium_render/pdf/document/page/
text.rs

1//! Defines the [PdfPageText] struct, exposing functionality related to the
2//! collection of Unicode characters visible on a single [PdfPage].
3
4pub mod char;
5pub mod chars;
6pub mod search;
7pub mod segment;
8pub mod segments;
9
10use crate::bindgen::{FPDF_TEXTPAGE, FPDF_WCHAR, FPDF_WIDESTRING};
11use crate::bindings::PdfiumLibraryBindings;
12use crate::error::PdfiumError;
13use crate::pdf::document::page::PdfPage;
14use crate::pdf::document::page::annotation::PdfPageAnnotation;
15use crate::pdf::document::page::annotation::PdfPageAnnotationCommon;
16use crate::pdf::document::page::object::private::internal::PdfPageObjectPrivate;
17use crate::pdf::document::page::object::text::PdfPageTextObject;
18use crate::pdf::document::page::text::chars::{PdfPageTextCharIndex, PdfPageTextChars};
19use crate::pdf::document::page::text::search::{PdfPageTextSearch, PdfSearchOptions};
20use crate::pdf::document::page::text::segments::PdfPageTextSegments;
21use crate::pdf::points::PdfPoints;
22use crate::pdf::rect::PdfRect;
23use crate::utils::mem::{create_byte_buffer, create_sized_buffer};
24use crate::utils::utf16le::{get_pdfium_utf16le_bytes_from_str, get_string_from_pdfium_utf16le_bytes};
25use bytemuck::cast_slice;
26use std::fmt::{Display, Formatter};
27use std::os::raw::{c_double, c_int};
28use std::ptr::null_mut;
29
30/// Shared gap-based space filtering for respaced text methods.
31///
32/// Iterates `chars` and builds a `String`, emitting a space for each generated
33/// space character only when the horizontal gap to the next real character
34/// exceeds `font_size * space_ratio`. This is the single implementation shared
35/// by `all_respaced`, `inside_rect_respaced`, and `PdfPageTextSegment::text_respaced`.
36/// Filter generated spaces using direct FFI calls for minimal overhead.
37///
38/// Single pass over character indices. For each character:
39/// - Non-generated: 2 FFI calls (GetUnicode + GetCharBox)
40/// - Generated space: 1 FFI call (IsGenerated), plus GetCharBox only for the next
41///   non-generated char to measure the gap
42///
43/// This avoids the PdfPageTextChar wrapper overhead and minimizes FFI roundtrips.
44pub(super) fn filter_generated_spaces_direct(
45    text_page_handle: crate::bindgen::FPDF_TEXTPAGE,
46    start: i32,
47    count: i32,
48    space_ratio: f32,
49    bindings: &dyn PdfiumLibraryBindings,
50) -> String {
51    if count <= 0 {
52        return String::new();
53    }
54
55    let end = start + count;
56    let mut result = String::with_capacity(count as usize);
57    let mut prev_right_x: Option<f32> = None;
58    let mut prev_font_size: f32 = 12.0;
59
60    let mut i = start;
61    while i < end {
62        let idx = i as std::os::raw::c_int;
63
64        if bindings.FPDFText_IsGenerated(text_page_handle, idx) != 0 {
65            if let Some(prev_r) = prev_right_x {
66                let mut j = i + 1;
67                while j < end {
68                    let jdx = j as std::os::raw::c_int;
69                    if bindings.FPDFText_IsGenerated(text_page_handle, jdx) == 0 {
70                        let mut left = 0.0_f64;
71                        let mut bottom = 0.0_f64;
72                        let mut right = 0.0_f64;
73                        let mut top = 0.0_f64;
74                        if bindings.FPDFText_GetCharBox(
75                            text_page_handle,
76                            jdx,
77                            &mut left,
78                            &mut right,
79                            &mut bottom,
80                            &mut top,
81                        ) != 0
82                        {
83                            let gap = left as f32 - prev_r;
84                            let next_fs = bindings.FPDFText_GetFontSize(text_page_handle, jdx) as f32;
85                            let ref_fs = if next_fs > 0.0 { next_fs } else { prev_font_size };
86                            if gap > ref_fs * space_ratio {
87                                result.push(' ');
88                            }
89                        } else {
90                            result.push(' ');
91                        }
92                        break;
93                    }
94                    j += 1;
95                }
96                if j >= end {
97                    result.push(' ');
98                }
99            }
100            i += 1;
101            continue;
102        }
103
104        let unicode_val = bindings.FPDFText_GetUnicode(text_page_handle, idx);
105        if let Some(uc) = char::from_u32(unicode_val) {
106            if uc == '\r' {
107                result.push('\n');
108                prev_right_x = None;
109                i += 1;
110                continue;
111            }
112            if !uc.is_control() || uc == '\n' || uc == '\t' {
113                result.push(uc);
114            }
115        }
116
117        let mut left = 0.0_f64;
118        let mut bottom = 0.0_f64;
119        let mut right = 0.0_f64;
120        let mut top = 0.0_f64;
121        if bindings.FPDFText_GetCharBox(text_page_handle, idx, &mut left, &mut right, &mut bottom, &mut top) != 0 {
122            prev_right_x = Some(right as f32);
123        }
124
125        let fs = bindings.FPDFText_GetFontSize(text_page_handle, idx) as f32;
126        if fs > 0.0 {
127            prev_font_size = fs;
128        }
129
130        i += 1;
131    }
132
133    result
134}
135
136/// The collection of Unicode characters visible on a single [PdfPage].
137///
138/// Use the [PdfPageText::all()] function to easily return all characters in the containing
139/// [PdfPage] in the order in which they are defined in the PDF file.
140///
141/// Use the [PdfPageText::search()] function to initialise a new [PdfPageTextSearch] object,
142/// yielding the results of searching for a target string within the character collection.
143///
144/// In complex custom layouts, the order in which characters are defined in the document
145/// and the order in which they appear visually during rendering (and thus the order in
146/// which they are read by a user) may not necessarily match.
147///
148/// [PdfPageText] implements both the [ToString] and the [Display] traits.
149pub struct PdfPageText<'a> {
150    text_page_handle: FPDF_TEXTPAGE,
151    page: &'a PdfPage<'a>,
152    bindings: &'a dyn PdfiumLibraryBindings,
153}
154
155impl<'a> PdfPageText<'a> {
156    pub(crate) fn from_pdfium(
157        text_page_handle: FPDF_TEXTPAGE,
158        page: &'a PdfPage<'a>,
159        bindings: &'a dyn PdfiumLibraryBindings,
160    ) -> Self {
161        PdfPageText {
162            text_page_handle,
163            page,
164            bindings,
165        }
166    }
167
168    /// Returns the internal `FPDF_TEXTPAGE` handle for this [PdfPageText].
169    #[inline]
170    pub(crate) fn text_page_handle(&self) -> FPDF_TEXTPAGE {
171        self.text_page_handle
172    }
173
174    /// Returns the [PdfiumLibraryBindings] used by this [PdfPageText].
175    #[inline]
176    pub fn bindings(&self) -> &'a dyn PdfiumLibraryBindings {
177        self.bindings
178    }
179
180    /// Returns the total number of characters in all text segments in the containing [PdfPage].
181    ///
182    /// The character count includes whitespace and newlines, and so may differ slightly
183    /// from the result of calling `PdfPageText::all().len()`.
184    #[inline]
185    pub fn len(&self) -> i32 {
186        self.bindings.FPDFText_CountChars(self.text_page_handle())
187    }
188
189    /// Returns `true` if there are no characters in any text box collection in the containing [PdfPage].
190    #[inline]
191    pub fn is_empty(&self) -> bool {
192        self.len() == 0
193    }
194
195    /// Returns a collection of all the `PdfPageTextSegment` text segments in the containing [PdfPage].
196    #[inline]
197    pub fn segments(&self) -> PdfPageTextSegments<'_> {
198        PdfPageTextSegments::new(self, 0, self.len(), self.bindings())
199    }
200
201    /// Returns a subset of the `PdfPageTextSegment` text segments in the containing [PdfPage].
202    /// Only text segments containing characters in the given index range will be included.
203    #[inline]
204    pub fn segments_subset(&self, start: PdfPageTextCharIndex, count: PdfPageTextCharIndex) -> PdfPageTextSegments<'_> {
205        PdfPageTextSegments::new(self, start as i32, count as i32, self.bindings())
206    }
207
208    /// Returns a collection of all the `PdfPageTextChar` characters in the containing [PdfPage].
209    #[inline]
210    pub fn chars(&self) -> PdfPageTextChars<'_> {
211        PdfPageTextChars::new(
212            self.page.document_handle(),
213            self.page.page_handle(),
214            self.text_page_handle(),
215            (0..self.len()).collect(),
216            self.bindings(),
217        )
218    }
219
220    /// Returns a collection of all the `PdfPageTextChar` characters in the given [PdfPageTextObject].
221    ///
222    /// The return result will be empty if the given [PdfPageTextObject] is not attached to the
223    /// containing [PdfPage].
224    #[inline]
225    pub fn chars_for_object(&self, object: &PdfPageTextObject) -> Result<PdfPageTextChars<'_>, PdfiumError> {
226        Ok(PdfPageTextChars::new(
227            self.page.document_handle(),
228            self.page.page_handle(),
229            self.text_page_handle(),
230            self.chars()
231                .iter()
232                .filter(|char| {
233                    self.bindings
234                        .FPDFText_GetTextObject(self.text_page_handle(), char.index() as i32)
235                        == object.object_handle()
236                })
237                .map(|char| char.index() as i32)
238                .collect(),
239            self.bindings(),
240        ))
241    }
242
243    /// Returns a collection of all the `PdfPageTextChar` characters in the given [PdfPageAnnotation].
244    ///
245    /// The return result will be empty if the given [PdfPageAnnotation] is not attached to the
246    /// containing [PdfPage].
247    #[inline]
248    pub fn chars_for_annotation(&self, annotation: &PdfPageAnnotation) -> Result<PdfPageTextChars<'_>, PdfiumError> {
249        self.chars_inside_rect(annotation.bounds()?)
250            .map_err(|_| PdfiumError::NoCharsInAnnotation)
251    }
252
253    /// Returns a collection of all the `PdfPageTextChar` characters that lie within the bounds of
254    /// the given [PdfRect] in the containing [PdfPage].
255    #[inline]
256    pub fn chars_inside_rect(&self, rect: PdfRect) -> Result<PdfPageTextChars<'_>, PdfiumError> {
257        let tolerance_x = rect.width() / 2.0;
258        let tolerance_y = rect.height() / 2.0;
259        let center_height = rect.bottom() + tolerance_y;
260
261        match (
262            Self::get_char_index_near_point(
263                self.text_page_handle(),
264                rect.left(),
265                tolerance_x,
266                center_height,
267                tolerance_y,
268                self.bindings(),
269            ),
270            Self::get_char_index_near_point(
271                self.text_page_handle(),
272                rect.right(),
273                tolerance_x,
274                center_height,
275                tolerance_y,
276                self.bindings(),
277            ),
278        ) {
279            (Some(start), Some(end)) => Ok(PdfPageTextChars::new(
280                self.page.document_handle(),
281                self.page.page_handle(),
282                self.text_page_handle(),
283                (start as i32..=end as i32 + 1).collect(),
284                self.bindings,
285            )),
286            (Some(start), None) => Ok(PdfPageTextChars::new(
287                self.page.document_handle(),
288                self.page.page_handle(),
289                self.text_page_handle(),
290                (start as i32..=start as i32 + 1).collect(),
291                self.bindings,
292            )),
293            (None, Some(end)) => Ok(PdfPageTextChars::new(
294                self.page.document_handle(),
295                self.page.page_handle(),
296                self.text_page_handle(),
297                (end as i32..=end as i32 + 1).collect(),
298                self.bindings,
299            )),
300            _ => Err(PdfiumError::NoCharsInRect),
301        }
302    }
303
304    /// Returns the character near to the given x and y positions on the containing [PdfPage],
305    /// if any. The returned character will be no further from the given positions than the given
306    /// tolerance values.
307    pub(crate) fn get_char_index_near_point(
308        text_page_handle: FPDF_TEXTPAGE,
309        x: PdfPoints,
310        tolerance_x: PdfPoints,
311        y: PdfPoints,
312        tolerance_y: PdfPoints,
313        bindings: &dyn PdfiumLibraryBindings,
314    ) -> Option<PdfPageTextCharIndex> {
315        match bindings.FPDFText_GetCharIndexAtPos(
316            text_page_handle,
317            x.value as c_double,
318            y.value as c_double,
319            tolerance_x.value as c_double,
320            tolerance_y.value as c_double,
321        ) {
322            -1 => None,
323            -3 => None,
324            index => Some(index as PdfPageTextCharIndex),
325        }
326    }
327
328    /// Returns all characters that lie within the containing [PdfPage], in the order in which
329    /// they are defined in the document, concatenated into a single string.
330    ///
331    /// In complex custom layouts, the order in which characters are defined in the document
332    /// and the order in which they appear visually during rendering (and thus the order in
333    /// which they are read by a user) may not necessarily match.
334    pub fn all(&self) -> String {
335        self.inside_rect(self.page.page_size())
336    }
337
338    /// Returns all page text with corrected word spacing, filtering out spurious
339    /// spaces that pdfium inserts mid-word due to aggressive inter-glyph heuristics.
340    ///
341    /// For each generated space character (`is_generated() == true`), checks whether
342    /// the horizontal gap between the preceding character's right edge and the following
343    /// character's left edge exceeds `font_size * space_ratio`. Only inserts a space
344    /// when the gap is large enough to represent a true word boundary.
345    ///
346    /// `space_ratio` controls sensitivity: 0.25 matches MinerU's threshold.
347    pub fn all_respaced(&self, space_ratio: f32) -> String {
348        let count = self.len();
349        filter_generated_spaces_direct(self.text_page_handle(), 0, count, space_ratio, self.bindings)
350    }
351
352    /// Returns all characters that lie within the bounds of the given [PdfRect] in the
353    /// containing [PdfPage], in the order in which they are defined in the document,
354    /// concatenated into a single string.
355    ///
356    /// In complex custom layouts, the order in which characters are defined in the document
357    /// and the order in which they appear visually during rendering (and thus the order in
358    /// which they are read by a user) may not necessarily match.
359    pub fn inside_rect(&self, rect: PdfRect) -> String {
360        let left = rect.left().value as f64;
361
362        let top = rect.top().value as f64;
363
364        let right = rect.right().value as f64;
365
366        let bottom = rect.bottom().value as f64;
367
368        let chars_count =
369            self.bindings()
370                .FPDFText_GetBoundedText(self.text_page_handle(), left, top, right, bottom, null_mut(), 0);
371
372        if chars_count == 0 {
373            return String::new();
374        }
375
376        let mut buffer = create_sized_buffer(chars_count as usize);
377
378        let result = self.bindings().FPDFText_GetBoundedText(
379            self.text_page_handle(),
380            left,
381            top,
382            right,
383            bottom,
384            buffer.as_mut_ptr(),
385            chars_count,
386        );
387
388        assert_eq!(result, chars_count);
389
390        get_string_from_pdfium_utf16le_bytes(cast_slice(buffer.as_slice()).to_vec()).unwrap_or_default()
391    }
392
393    /// Returns text within the given rectangle with corrected word spacing.
394    ///
395    /// Like [`inside_rect()`] but reconstructs text from individual characters,
396    /// filtering out spurious spaces that pdfium inserts mid-word.
397    /// See [`all_respaced()`] for details on the `space_ratio` parameter.
398    pub fn inside_rect_respaced(&self, rect: PdfRect, space_ratio: f32) -> String {
399        let chars = match self.chars_inside_rect(rect) {
400            Ok(c) => c,
401            Err(_) => {
402                log::warn!("chars_inside_rect failed, falling back to unrespaced text");
403                return self.inside_rect(rect);
404            }
405        };
406        let count = chars.len();
407        if count == 0 {
408            return String::new();
409        }
410        let start = chars.first_char_index().unwrap_or(0) as i32;
411        filter_generated_spaces_direct(self.text_page_handle(), start, count as i32, space_ratio, self.bindings)
412    }
413
414    /// Returns all characters assigned to the given [PdfPageTextObject] in this [PdfPageText] object,
415    /// concatenated into a single string.
416    pub fn for_object(&self, object: &PdfPageTextObject) -> String {
417        let buffer_length =
418            self.bindings()
419                .FPDFTextObj_GetText(object.object_handle(), self.text_page_handle(), null_mut(), 0);
420
421        if buffer_length == 0 {
422            return String::new();
423        }
424
425        let mut buffer = create_byte_buffer(buffer_length as usize);
426
427        let result = self.bindings().FPDFTextObj_GetText(
428            object.object_handle(),
429            self.text_page_handle(),
430            buffer.as_mut_ptr() as *mut FPDF_WCHAR,
431            buffer_length,
432        );
433
434        assert_eq!(result, buffer_length);
435
436        get_string_from_pdfium_utf16le_bytes(buffer).unwrap_or_default()
437    }
438
439    /// Returns the raw `FPDF_PAGEOBJECT` handle for the text object that contains
440    /// the character at the given index, or `None` if the index is out of range or
441    /// the character is not associated with a text object (e.g. generated chars).
442    ///
443    /// The returned handle is an opaque pointer suitable for identity comparison
444    /// (same pointer = same text object). Cast to `usize` for storage.
445    pub fn text_object_for_char_index(&self, index: usize) -> Option<usize> {
446        let handle = self
447            .bindings()
448            .FPDFText_GetTextObject(self.text_page_handle(), index as std::ffi::c_int);
449        if handle.is_null() { None } else { Some(handle as usize) }
450    }
451
452    /// Returns all characters that lie within the bounds of the given [PdfPageAnnotation] in the
453    /// containing [PdfPage], in the order in which they are defined in the document,
454    /// concatenated into a single string.
455    ///
456    /// In complex custom layouts, the order in which characters are defined in the document
457    /// and the order in which they appear visually during rendering (and thus the order in
458    /// which they are read by a user) may not necessarily match.
459    #[inline]
460    pub fn for_annotation(&self, annotation: &PdfPageAnnotation) -> Result<String, PdfiumError> {
461        let bounds = annotation.bounds()?;
462
463        Ok(self.inside_rect(bounds))
464    }
465
466    /// Starts a search for the given text string, returning a new [PdfPageTextSearch]
467    /// object that can be used to step through the search results.
468    #[inline]
469    pub fn search(&self, text: &str, options: &PdfSearchOptions) -> Result<PdfPageTextSearch<'_>, PdfiumError> {
470        self.search_from(text, options, 0)
471    }
472
473    /// Starts a search for the given test string from the given character position,
474    /// returning a new [PdfPageTextSearch] object that can be used to step through
475    /// the search results.
476    pub fn search_from(
477        &self,
478        text: &str,
479        options: &PdfSearchOptions,
480        index: PdfPageTextCharIndex,
481    ) -> Result<PdfPageTextSearch<'_>, PdfiumError> {
482        if text.is_empty() {
483            Err(PdfiumError::TextSearchTargetIsEmpty)
484        } else {
485            Ok(PdfPageTextSearch::from_pdfium(
486                self.bindings().FPDFText_FindStart(
487                    self.text_page_handle(),
488                    get_pdfium_utf16le_bytes_from_str(text).as_ptr() as FPDF_WIDESTRING,
489                    options.as_pdfium(),
490                    index as c_int,
491                ),
492                self,
493                self.bindings(),
494            ))
495        }
496    }
497}
498
499impl<'a> Display for PdfPageText<'a> {
500    #[inline]
501    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
502        f.write_str(self.all().as_str())
503    }
504}
505
506impl<'a> Drop for PdfPageText<'a> {
507    /// Closes the [PdfPageText] collection, releasing held memory.
508    #[inline]
509    fn drop(&mut self) {
510        self.bindings().FPDFText_ClosePage(self.text_page_handle());
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use itertools::Itertools;
517    use std::ffi::OsStr;
518    use std::fs;
519
520    use crate::prelude::*;
521    use crate::utils::test::{test_bind_to_pdfium, test_fixture_path};
522
523    #[test]
524    fn test_overlapping_chars_results() -> Result<(), PdfiumError> {
525        let pdfium = test_bind_to_pdfium();
526
527        let mut document = pdfium.create_new_pdf()?;
528
529        let mut page = document.pages_mut().create_page_at_start(PdfPagePaperSize::a4())?;
530
531        let font = document.fonts_mut().courier();
532
533        let txt1 = page.objects_mut().create_text_object(
534            PdfPoints::ZERO,
535            PdfPoints::ZERO,
536            "AAAAAA",
537            font,
538            PdfPoints::new(10.0),
539        )?;
540
541        let txt2 = page.objects_mut().create_text_object(
542            PdfPoints::ZERO,
543            PdfPoints::ZERO,
544            "BBBBBB",
545            font,
546            PdfPoints::new(10.0),
547        )?;
548
549        let txt3 = page.objects_mut().create_text_object(
550            PdfPoints::ZERO,
551            PdfPoints::ZERO,
552            "CDCDCDE",
553            font,
554            PdfPoints::new(10.0),
555        )?;
556
557        let page_text = page.text()?;
558
559        assert!(test_one_overlapping_text_object_results(&txt1, &page_text, "AAAAAA")?);
560        assert!(test_one_overlapping_text_object_results(&txt2, &page_text, "BBBBBB")?);
561        assert!(test_one_overlapping_text_object_results(&txt3, &page_text, "CDCDCDE")?);
562
563        Ok(())
564    }
565
566    fn test_one_overlapping_text_object_results(
567        object: &PdfPageObject,
568        page_text: &PdfPageText,
569        expected: &str,
570    ) -> Result<bool, PdfiumError> {
571        if let Some(txt) = object.as_text_object() {
572            assert_eq!(txt.text().trim(), expected);
573            assert_eq!(page_text.for_object(txt).trim(), expected);
574
575            for (index, char) in txt.chars(page_text)?.iter().enumerate() {
576                assert_eq!(txt.text().chars().nth(index), char.unicode_char());
577                assert_eq!(expected.chars().nth(index), char.unicode_char());
578            }
579
580            Ok(true)
581        } else {
582            Ok(false)
583        }
584    }
585
586    #[test]
587    fn test_text_chars_results_equality() -> Result<(), PdfiumError> {
588        let pdfium = test_bind_to_pdfium();
589
590        let fixture_dir = test_fixture_path("");
591        let samples = fs::read_dir(&fixture_dir)
592            .unwrap()
593            .filter_map(|entry| match entry {
594                Ok(e) => Some(e.path()),
595                Err(_) => None,
596            })
597            .filter(|path| path.extension() == Some(OsStr::new("pdf")))
598            .collect::<Vec<_>>();
599
600        assert!(!samples.is_empty());
601
602        for sample in samples {
603            println!("Testing all text objects in file {}", sample.display());
604
605            let document = pdfium.load_pdf_from_file(&sample, None)?;
606
607            for page in document.pages().iter() {
608                let text = page.text()?;
609
610                for object in page.objects().iter() {
611                    if let Some(obj) = object.as_text_object() {
612                        let chars = obj
613                            .chars(&text)?
614                            .iter()
615                            .filter_map(|char| char.unicode_string())
616                            .join("");
617
618                        assert_eq!(obj.text().trim(), chars.replace("\0", "").trim());
619                    }
620                }
621            }
622        }
623
624        Ok(())
625    }
626}