Skip to main content

pdfrum_text/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4// Every number reaching this crate came from an untrusted file, by way of the
5// page interpreter: index with `get()` and do arithmetic that cannot trap.
6#![warn(clippy::indexing_slicing)]
7
8// Every module is private and the crate root is the whole surface:
9// a caller of `pdfrum-text` needs the types below, and the
10// bidi resolver, the Unicode tables, the link scanners and the segment
11// builder are how this crate reaches them, not what it offers.
12mod bidi;
13mod charinfo;
14mod dedup;
15mod error;
16mod find;
17mod index;
18mod line;
19mod links;
20mod object;
21mod orientation;
22mod pipeline;
23mod select;
24mod unicode;
25mod word;
26
27pub use charinfo::{CharBox, CharType, ObjectIndex};
28pub use error::Error;
29pub use find::FindOptions;
30pub use index::{CharIndex, CharSegment, IndexMap, TextIndex};
31pub use links::WebLink;
32pub use object::TextRun;
33pub use orientation::Orientation;
34pub use word::Word;
35
36/// The two candidate scanners [`WebLink`] detection is built from, and the
37/// range type they report.
38///
39/// **Not caller API**, and not a stable one: they are the crate's most
40/// index-heavy code and `fuzz/fuzz_targets/text_links.rs` drives them
41/// directly on arbitrary strings, which is worth more than keeping them
42/// unreachable. A caller wants [`TextPage::web_links`].
43#[doc(hidden)]
44pub use links::{FoundLink, check_mail_link, check_web_link};
45
46use kurbo::{Affine, Point, Rect, Size};
47use pdfrum_common::{DiagKind, Diagnostics, Limits, Operation, Severity};
48use pdfrum_object::Resolve;
49use pdfrum_page::Page;
50use std::collections::BTreeMap;
51use std::ops::{Range, RangeBounds};
52
53/// How extraction behaves.
54///
55/// # Examples
56///
57/// ```
58/// use pdfrum_text::ExtractOptions;
59///
60/// // The default reads direction from each line's own text.
61/// assert!(!ExtractOptions::default().rtl);
62///
63/// // A document whose catalog says `/ViewerPreferences << /Direction /R2L >>`
64/// // must be extracted with this set, or every line comes out in the wrong
65/// // order.
66/// let options = ExtractOptions { rtl: true };
67/// assert!(options.rtl);
68/// ```
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
70pub struct ExtractOptions {
71    /// The document's `/Root /ViewerPreferences /Direction` is `R2L`.
72    ///
73    /// Forces every line's overall direction right-to-left, which reverses
74    /// the *order* of its direction runs. A caller reading this out of the
75    /// catalog must set it, or every document with that preference extracts
76    /// in the wrong order.
77    pub rtl: bool,
78}
79
80/// One page's extracted text (ISO 32000-1 §14.8.2).
81///
82/// Holds **two sequences that are not the same**, and conflating them is the
83/// easiest way to get this crate wrong:
84///
85/// - [`chars`](Self::chars), addressed by [`CharIndex`], is every character
86///   the page drew or the extractor invented, geometry attached. It keeps
87///   control characters, `\0` for an unmappable code, and `U+0002` where a
88///   word was hyphenated across a line.
89/// - [`search_text`](Self::search_text), addressed by [`TextIndex`], is what
90///   a search matches and a selection copies. It drops the control characters
91///   and the placeholders, expands ligatures the character stream keeps
92///   whole, and carries `U+00AD` at a hyphenated break and `U+FFFD` for an
93///   unmappable code, so it can disagree with `chars` position by position.
94///
95/// [`runs`](Self::runs) converts between the two spaces; every signature
96/// names which one it counts in. Cheap to clone and `Send + Sync`, so a
97/// document's pages can be extracted in parallel.
98///
99/// # Examples
100///
101/// The fields are public, so a page can be built by hand — which is how the
102/// query side is exercised without a file:
103///
104/// ```
105/// use pdfrum_text::TextPage;
106///
107/// let page = TextPage {
108///     search_text: "Hello, world!".chars().collect(),
109///     ..TextPage::default()
110/// };
111/// // `Display` writes the search-facing text, never the character stream.
112/// assert_eq!(page.to_string(), "Hello, world!");
113/// // …which is a different sequence, and here an empty one.
114/// assert_eq!(page.char_count(), 0);
115/// ```
116#[derive(Debug, Clone, Default)]
117pub struct TextPage {
118    /// Characters in reading order addressed by [`CharIndex`].
119    pub chars: Vec<CharBox>,
120    /// Normalized search-facing text addressed by [`TextIndex`].
121    pub search_text: Vec<char>,
122    /// Map between [`CharIndex`] and [`TextIndex`].
123    pub runs: IndexMap,
124    /// The base font name of each text object whose font has one, by the
125    /// [`ObjectIndex`] its characters carry. A Type 3 font has no base name
126    /// and is absent. Read through a character with
127    /// [`font_name`](Self::font_name).
128    pub fonts: BTreeMap<ObjectIndex, String>,
129}
130
131/// Extracts text, layout, and reading order from an interpreted page
132/// (ISO 32000-1 §14.8.2).
133///
134/// Infallible and never panicking: every place something is silently dropped
135/// records a [`Diagnostic`](pdfrum_common::Diagnostic) into `diags` and
136/// carries on. A `limits.deadline` that has passed is read once, here on
137/// entry — a page is the extractor's unit of work — and answers an empty
138/// page with [`DiagKind::TimeLimitReached`].
139///
140/// # Examples
141///
142/// A page with nothing on it extracts to nothing, without an error and
143/// without a diagnostic:
144///
145/// ```
146/// use pdfrum_common::{Diagnostics, Limits};
147/// use pdfrum_object::NoResolve;
148/// use pdfrum_page::Page;
149/// use pdfrum_text::{ExtractOptions, extract};
150///
151/// let mut diags = Diagnostics::default();
152/// let text = extract(
153///     &Page::empty(),
154///     &NoResolve,
155///     &ExtractOptions::default(),
156///     &Limits::default(),
157///     &mut diags,
158/// );
159///
160/// assert_eq!(text.char_count(), 0);
161/// assert_eq!(text.to_string(), "");
162/// assert!(diags.entries().is_empty());
163/// ```
164#[must_use]
165pub fn extract<R: Resolve>(
166    page: &Page,
167    resolver: &R,
168    options: &ExtractOptions,
169    limits: &Limits,
170    diags: &mut Diagnostics,
171) -> TextPage {
172    if limits.check_deadline(Operation::Extract).is_err() {
173        diags.record(Severity::Suspicious, DiagKind::TimeLimitReached, None);
174        return TextPage::default();
175    }
176    if page.objects.is_empty() {
177        return TextPage::default();
178    }
179    let runs = object::walk(&page.objects);
180    let page_flow = orientation::page_flow(page, &runs);
181    let display = display_matrix(page);
182
183    let mut builder = pipeline::Builder::new(&runs, page_flow, display, options.rtl, resolver);
184    // Object-level duplicate suppression looks back over the text objects
185    // already offered, so the walk keeps them as it goes.
186    let mut offered: Vec<TextRun> = Vec::new();
187    for (index, run) in runs.iter().enumerate() {
188        if dedup::repeats_a_predecessor(run, &offered, &builder.out.chars) {
189            diags.record(
190                pdfrum_common::Severity::Recovered,
191                pdfrum_common::DiagKind::TextObjectDuplicate,
192                None,
193            );
194            continue;
195        }
196        offered.push(run.clone());
197        builder.offer(index, diags);
198    }
199    builder.flush(diags);
200    builder.close_line();
201
202    let out = builder.out;
203    let text: Vec<char> = out
204        .text
205        .iter()
206        .filter_map(|unit| char::from_u32(*unit))
207        .collect();
208    let fonts = runs
209        .iter()
210        .filter(|run| !run.font.base_font_name().is_empty())
211        .map(|run| {
212            let name = String::from_utf8_lossy(run.font.base_font_name()).into_owned();
213            (run.index, name)
214        })
215        .collect();
216    let runs = index::build(&out.chars);
217    TextPage {
218        chars: out.chars,
219        search_text: text,
220        runs,
221        fonts,
222    }
223}
224
225/// The page-space to device-space matrix the batching and one line-break
226/// escape hatch measure in.
227///
228/// A y-flip composed with the crop box's own normalizer, which for an
229/// unrotated page is `(1, 0, 0, -1, 0, height)`. A page with a zero
230/// dimension gets the **zero matrix**, which collapses every position to the
231/// origin — so no batch is ever split on such a page, and the escape hatch
232/// never fires.
233fn display_matrix(page: &Page) -> Affine {
234    let (width, height) = page.display_size();
235    if width <= 0.0 || height <= 0.0 {
236        return Affine::new([0.0; 6]);
237    }
238    let normalizer = page.rotate.display_matrix(page.crop_box);
239    Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, height]) * normalizer
240}
241
242/// Resolves a caller's character range into the half-open `start..end` a
243/// query wants, clamped to the character list.
244///
245/// An unbounded end is "to the end of the page", which is the shape the C++
246/// spells as a negative count.
247fn char_bounds(range: &impl RangeBounds<CharIndex>, total: usize) -> (usize, usize) {
248    use std::ops::Bound;
249    let start = match range.start_bound() {
250        Bound::Included(at) => at.get(),
251        Bound::Excluded(at) => at.get().saturating_add(1),
252        Bound::Unbounded => 0,
253    };
254    let end = match range.end_bound() {
255        Bound::Included(at) => at.get().saturating_add(1),
256        Bound::Excluded(at) => at.get(),
257        Bound::Unbounded => total,
258    };
259    (start, end.min(total))
260}
261
262impl TextPage {
263    /// How many characters the page drew.
264    ///
265    /// This counts [`chars`](Self::chars), not
266    /// [`search_text`](Self::search_text): the two sequences have different
267    /// lengths, and a page can have text and no characters or the reverse.
268    ///
269    /// # Examples
270    ///
271    /// ```
272    /// use pdfrum_text::TextPage;
273    ///
274    /// let page = TextPage {
275    ///     search_text: "Hello".chars().collect(),
276    ///     ..TextPage::default()
277    /// };
278    /// assert_eq!(page.to_string().len(), 5);
279    /// assert_eq!(page.char_count(), 0);
280    /// ```
281    #[must_use]
282    pub fn char_count(&self) -> usize {
283        self.chars.len()
284    }
285
286    /// A run of the [`search_text`](Self::search_text), addressed in
287    /// [`CharIndex`].
288    ///
289    /// The bounds are **widened onto real text**, not filtered: a start on a
290    /// character the text does not hold scans forward to the next one it
291    /// does, and an end on one scans back. Characters inside the range are
292    /// never skipped. So on `control_characters.pdf`, asking for the fifteen
293    /// characters from character 17 returns `"Goodbye, world!"` even though
294    /// the text itself has no character 17.
295    ///
296    /// This is the one call where the two spaces of [`TextPage`] meet: the
297    /// bounds count characters and the answer is text.
298    ///
299    /// # Examples
300    ///
301    /// A page holding `"Hello, world!"` on one line and `"Goodbye, world!"` on
302    /// the next, with the extractor's generated `\r\n` between them:
303    ///
304    /// ```
305    /// # use pdfrum_text::CharIndex;
306    /// # use pdfrum_common::{Diagnostics, Limits};
307    /// # use pdfrum_object::{Name, Object};
308    /// # use pdfrum_page::{BuildContext, Resources, build_page_from_dict, parse_content};
309    /// # use std::sync::Arc;
310    /// # let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
311    /// # let doc = pdfrum_parser::load(bytes, &pdfrum_parser::LoadOptions::default())?;
312    /// # let loaded = doc.page(0)?;
313    /// # let (limits, mut diags) = (Limits::default(), Diagnostics::default());
314    /// # let mut content = Vec::new();
315    /// # if let Some(contents) = loaded.dict.get(&Name::from("Contents"), &doc)
316    /// #     && let Some(Object::Stream(stream)) = contents.as_direct() {
317    /// #     content.extend_from_slice(
318    /// #         &pdfrum_filters::decode_chain(stream, 0, &doc, &limits, &mut diags).data);
319    /// # }
320    /// # let ops = parse_content(&content, &limits, &mut diags);
321    /// # let resources = Resources::for_page(
322    /// #     loaded.inherited(&Name::from("Resources"), &doc)
323    /// #         .and_then(|o| o.resolve(&doc).ok()?.as_dict().cloned()));
324    /// # let built = build_page_from_dict(&ops, &loaded.dict, |k| loaded.inherited(k, &doc),
325    /// #     &resources, &doc, &mut BuildContext::default(), &limits, &mut diags);
326    /// # let page = pdfrum_text::extract(&built, &doc, &pdfrum_text::ExtractOptions::default(),
327    /// #     &limits, &mut diags);
328    /// let at = CharIndex::new;
329    /// assert_eq!(page.slice(at(0)..at(5)), "Hello");
330    /// // An unbounded end is "to the end of the page".
331    /// assert_eq!(page.slice(at(15)..), "Goodbye, world!");
332    /// assert_eq!(page.slice(..), "Hello, world!\r\nGoodbye, world!");
333    /// # Ok::<(), Box<dyn std::error::Error>>(())
334    /// ```
335    #[must_use]
336    pub fn slice(&self, range: impl RangeBounds<CharIndex>) -> String {
337        let total = self.chars.len();
338        let (start, end) = char_bounds(&range, total);
339        if start >= end || start >= total || self.search_text.is_empty() {
340            return String::new();
341        }
342        let Some(text_start) = self.runs.text_index_at_or_after(CharIndex::new(start)) else {
343            return String::new();
344        };
345        let text_end = self.runs.text_index_end(CharIndex::new(end - 1));
346        if text_end <= text_start {
347            return String::new();
348        }
349        self.search_text
350            .get(text_start.get()..text_end.get())
351            .unwrap_or_default()
352            .iter()
353            .collect()
354    }
355
356    /// Searches the page.
357    ///
358    /// Match ranges are [`TextIndex`] offsets into
359    /// [`search_text`](Self::search_text) — **not** the [`CharIndex`] space
360    /// [`web_links`](Self::web_links) reports; see [`TextPage`].
361    ///
362    /// # Examples
363    ///
364    /// ```
365    /// # use pdfrum_text::{FindOptions, TextIndex, TextPage};
366    /// let page = TextPage {
367    ///     search_text: "Hello, world!".chars().collect(),
368    ///     ..TextPage::default()
369    /// };
370    /// let at = TextIndex::new;
371    /// let hits: Vec<_> = page.find("world", FindOptions::default()).collect();
372    /// assert_eq!(hits, [at(7)..at(12)]);
373    /// // The default is case-insensitive.
374    /// let hits: Vec<_> = page.find("WORLD", FindOptions::default()).collect();
375    /// assert_eq!(hits, [at(7)..at(12)]);
376    /// ```
377    pub fn find<'a>(
378        &'a self,
379        needle: &str,
380        options: FindOptions,
381    ) -> impl Iterator<Item = Range<TextIndex>> + 'a {
382        find::search(&self.to_string(), needle, options)
383    }
384
385    /// Every web and mail address in the page's text.
386    ///
387    /// Reported ranges are [`CharIndex`] spans into [`chars`](Self::chars) —
388    /// **not** the [`TextIndex`] space [`find`](Self::find) returns. The two
389    /// index spaces are different sequences; see [`TextPage`].
390    /// # Examples
391    ///
392    /// The fixture below draws no address, so nothing is reported; a page that
393    /// draws `www.example.com` reports it with an `http://` already prefixed.
394    ///
395    /// ```
396    /// # use pdfrum_common::{Diagnostics, Limits};
397    /// # use pdfrum_object::{Name, Object};
398    /// # use pdfrum_page::{BuildContext, Resources, build_page_from_dict, parse_content};
399    /// # use std::sync::Arc;
400    /// # let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
401    /// # let doc = pdfrum_parser::load(bytes, &pdfrum_parser::LoadOptions::default())?;
402    /// # let loaded = doc.page(0)?;
403    /// # let (limits, mut diags) = (Limits::default(), Diagnostics::default());
404    /// # let mut content = Vec::new();
405    /// # if let Some(contents) = loaded.dict.get(&Name::from("Contents"), &doc)
406    /// #     && let Some(Object::Stream(stream)) = contents.as_direct() {
407    /// #     content.extend_from_slice(
408    /// #         &pdfrum_filters::decode_chain(stream, 0, &doc, &limits, &mut diags).data);
409    /// # }
410    /// # let ops = parse_content(&content, &limits, &mut diags);
411    /// # let resources = Resources::for_page(
412    /// #     loaded.inherited(&Name::from("Resources"), &doc)
413    /// #         .and_then(|o| o.resolve(&doc).ok()?.as_dict().cloned()));
414    /// # let built = build_page_from_dict(&ops, &loaded.dict, |k| loaded.inherited(k, &doc),
415    /// #     &resources, &doc, &mut BuildContext::default(), &limits, &mut diags);
416    /// # let page = pdfrum_text::extract(&built, &doc, &pdfrum_text::ExtractOptions::default(),
417    /// #     &limits, &mut diags);
418    /// assert!(page.web_links().is_empty());
419    /// # Ok::<(), Box<dyn std::error::Error>>(())
420    /// ```
421    #[must_use]
422    pub fn web_links(&self) -> Vec<WebLink> {
423        links::extract(&self.chars, &self.search_text, &index::build(&self.chars))
424    }
425
426    /// The boxes covering a run of [`CharIndex`], one per run of consecutive
427    /// characters sharing a text object.
428    ///
429    /// Generated characters and boxes under 0.01 in either dimension are
430    /// skipped, and a box is pushed **unconditionally at the end** — so a run
431    /// in which every character was skipped still yields one box, an all-zero
432    /// rectangle. An unbounded end is "to the end of the page", and a range
433    /// running past the end takes what is there.
434    /// # Examples
435    ///
436    /// Two lines set in two different fonts are two text objects, so the whole
437    /// page yields two boxes rather than one:
438    ///
439    /// ```
440    /// # use pdfrum_text::CharIndex;
441    /// # use pdfrum_common::{Diagnostics, Limits};
442    /// # use pdfrum_object::{Name, Object};
443    /// # use pdfrum_page::{BuildContext, Resources, build_page_from_dict, parse_content};
444    /// # use std::sync::Arc;
445    /// # let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
446    /// # let doc = pdfrum_parser::load(bytes, &pdfrum_parser::LoadOptions::default())?;
447    /// # let loaded = doc.page(0)?;
448    /// # let (limits, mut diags) = (Limits::default(), Diagnostics::default());
449    /// # let mut content = Vec::new();
450    /// # if let Some(contents) = loaded.dict.get(&Name::from("Contents"), &doc)
451    /// #     && let Some(Object::Stream(stream)) = contents.as_direct() {
452    /// #     content.extend_from_slice(
453    /// #         &pdfrum_filters::decode_chain(stream, 0, &doc, &limits, &mut diags).data);
454    /// # }
455    /// # let ops = parse_content(&content, &limits, &mut diags);
456    /// # let resources = Resources::for_page(
457    /// #     loaded.inherited(&Name::from("Resources"), &doc)
458    /// #         .and_then(|o| o.resolve(&doc).ok()?.as_dict().cloned()));
459    /// # let built = build_page_from_dict(&ops, &loaded.dict, |k| loaded.inherited(k, &doc),
460    /// #     &resources, &doc, &mut BuildContext::default(), &limits, &mut diags);
461    /// # let page = pdfrum_text::extract(&built, &doc, &pdfrum_text::ExtractOptions::default(),
462    /// #     &limits, &mut diags);
463    /// let boxes = page.rects(..);
464    /// assert_eq!(boxes.len(), 2);
465    /// // The first line sits below the second in page space, which is y-up.
466    /// assert!(boxes[0].y1 < boxes[1].y0);
467    ///
468    /// // A run inside one object is one box.
469    /// assert_eq!(page.rects(CharIndex::new(0)..CharIndex::new(5)).len(), 1);
470    /// # Ok::<(), Box<dyn std::error::Error>>(())
471    /// ```
472    #[must_use]
473    pub fn rects(&self, range: impl RangeBounds<CharIndex>) -> Vec<Rect> {
474        select::rects(&self.chars, range)
475    }
476
477    /// The character under a point in page space, or the nearest within tolerance.
478    /// A point inside a character's box wins outright and reports the
479    /// **first** such character; failing that, and only when a tolerance is
480    /// given, the nearest character within it.
481    ///
482    /// # Examples
483    ///
484    /// ```
485    /// # use kurbo::{Point, Size};
486    /// # use pdfrum_text::CharIndex;
487    /// # use pdfrum_common::{Diagnostics, Limits};
488    /// # use pdfrum_object::{Name, Object};
489    /// # use pdfrum_page::{BuildContext, Resources, build_page_from_dict, parse_content};
490    /// # use std::sync::Arc;
491    /// # let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
492    /// # let doc = pdfrum_parser::load(bytes, &pdfrum_parser::LoadOptions::default())?;
493    /// # let loaded = doc.page(0)?;
494    /// # let (limits, mut diags) = (Limits::default(), Diagnostics::default());
495    /// # let mut content = Vec::new();
496    /// # if let Some(contents) = loaded.dict.get(&Name::from("Contents"), &doc)
497    /// #     && let Some(Object::Stream(stream)) = contents.as_direct() {
498    /// #     content.extend_from_slice(
499    /// #         &pdfrum_filters::decode_chain(stream, 0, &doc, &limits, &mut diags).data);
500    /// # }
501    /// # let ops = parse_content(&content, &limits, &mut diags);
502    /// # let resources = Resources::for_page(
503    /// #     loaded.inherited(&Name::from("Resources"), &doc)
504    /// #         .and_then(|o| o.resolve(&doc).ok()?.as_dict().cloned()));
505    /// # let built = build_page_from_dict(&ops, &loaded.dict, |k| loaded.inherited(k, &doc),
506    /// #     &resources, &doc, &mut BuildContext::default(), &limits, &mut diags);
507    /// # let page = pdfrum_text::extract(&built, &doc, &pdfrum_text::ExtractOptions::default(),
508    /// #     &limits, &mut diags);
509    /// // Inside the first glyph's box.
510    /// assert_eq!(page.index_at(Point::new(24.0, 54.0), Size::ZERO), Some(CharIndex::new(0)));
511    /// // Far from every glyph, with no tolerance to fall back on.
512    /// assert_eq!(page.index_at(Point::new(500.0, 500.0), Size::ZERO), None);
513    /// # Ok::<(), Box<dyn std::error::Error>>(())
514    /// ```
515    #[must_use]
516    pub fn index_at(&self, point: Point, tolerance: Size) -> Option<CharIndex> {
517        select::index_at(&self.chars, point, tolerance)
518    }
519
520    /// The text inside a rectangle, with `\r\n` where the selection crosses a
521    /// baseline.
522    /// # Examples
523    ///
524    /// ```
525    /// # use kurbo::Rect;
526    /// # use pdfrum_common::{Diagnostics, Limits};
527    /// # use pdfrum_object::{Name, Object};
528    /// # use pdfrum_page::{BuildContext, Resources, build_page_from_dict, parse_content};
529    /// # use std::sync::Arc;
530    /// # let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
531    /// # let doc = pdfrum_parser::load(bytes, &pdfrum_parser::LoadOptions::default())?;
532    /// # let loaded = doc.page(0)?;
533    /// # let (limits, mut diags) = (Limits::default(), Diagnostics::default());
534    /// # let mut content = Vec::new();
535    /// # if let Some(contents) = loaded.dict.get(&Name::from("Contents"), &doc)
536    /// #     && let Some(Object::Stream(stream)) = contents.as_direct() {
537    /// #     content.extend_from_slice(
538    /// #         &pdfrum_filters::decode_chain(stream, 0, &doc, &limits, &mut diags).data);
539    /// # }
540    /// # let ops = parse_content(&content, &limits, &mut diags);
541    /// # let resources = Resources::for_page(
542    /// #     loaded.inherited(&Name::from("Resources"), &doc)
543    /// #         .and_then(|o| o.resolve(&doc).ok()?.as_dict().cloned()));
544    /// # let built = build_page_from_dict(&ops, &loaded.dict, |k| loaded.inherited(k, &doc),
545    /// #     &resources, &doc, &mut BuildContext::default(), &limits, &mut diags);
546    /// # let page = pdfrum_text::extract(&built, &doc, &pdfrum_text::ExtractOptions::default(),
547    /// #     &limits, &mut diags);
548    /// // A rectangle covering only the lower line takes only its text.
549    /// assert_eq!(page.text_in_rect(Rect::new(0.0, 0.0, 200.0, 70.0)), "Hello, world!");
550    /// # Ok::<(), Box<dyn std::error::Error>>(())
551    /// ```
552    #[must_use]
553    pub fn text_in_rect(&self, rect: Rect) -> String {
554        select::text_in_rect(&self.chars, rect)
555    }
556
557    /// The text drawn by a specific text object.
558    /// The [`ObjectIndex`] is the one a character carries in
559    /// [`CharBox::object`], counting text objects in content order.
560    ///
561    /// # Examples
562    ///
563    /// ```
564    /// # use pdfrum_text::ObjectIndex;
565    /// # use pdfrum_common::{Diagnostics, Limits};
566    /// # use pdfrum_object::{Name, Object};
567    /// # use pdfrum_page::{BuildContext, Resources, build_page_from_dict, parse_content};
568    /// # use std::sync::Arc;
569    /// # let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
570    /// # let doc = pdfrum_parser::load(bytes, &pdfrum_parser::LoadOptions::default())?;
571    /// # let loaded = doc.page(0)?;
572    /// # let (limits, mut diags) = (Limits::default(), Diagnostics::default());
573    /// # let mut content = Vec::new();
574    /// # if let Some(contents) = loaded.dict.get(&Name::from("Contents"), &doc)
575    /// #     && let Some(Object::Stream(stream)) = contents.as_direct() {
576    /// #     content.extend_from_slice(
577    /// #         &pdfrum_filters::decode_chain(stream, 0, &doc, &limits, &mut diags).data);
578    /// # }
579    /// # let ops = parse_content(&content, &limits, &mut diags);
580    /// # let resources = Resources::for_page(
581    /// #     loaded.inherited(&Name::from("Resources"), &doc)
582    /// #         .and_then(|o| o.resolve(&doc).ok()?.as_dict().cloned()));
583    /// # let built = build_page_from_dict(&ops, &loaded.dict, |k| loaded.inherited(k, &doc),
584    /// #     &resources, &doc, &mut BuildContext::default(), &limits, &mut diags);
585    /// # let page = pdfrum_text::extract(&built, &doc, &pdfrum_text::ExtractOptions::default(),
586    /// #     &limits, &mut diags);
587    /// assert_eq!(page.text_of_object(ObjectIndex(0)), "Hello, world!");
588    /// assert_eq!(page.text_of_object(ObjectIndex(1)), "Goodbye, world!");
589    /// // An object the page does not have draws nothing.
590    /// assert_eq!(page.text_of_object(ObjectIndex(9)), "");
591    /// # Ok::<(), Box<dyn std::error::Error>>(())
592    /// ```
593    #[must_use]
594    pub fn text_of_object(&self, object: ObjectIndex) -> String {
595        select::text_of_object(&self.chars, object)
596    }
597
598    /// Returns the character box at the given [`CharIndex`].
599    ///
600    /// # Errors
601    ///
602    /// Returns [`Error::CharIndexOutOfRange`] when `index` is past the end of [`chars`](Self::chars).
603    /// # Examples
604    ///
605    /// ```
606    /// # use pdfrum_text::{CharIndex, CharType, Error};
607    /// # use pdfrum_common::{Diagnostics, Limits};
608    /// # use pdfrum_object::{Name, Object};
609    /// # use pdfrum_page::{BuildContext, Resources, build_page_from_dict, parse_content};
610    /// # use std::sync::Arc;
611    /// # let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
612    /// # let doc = pdfrum_parser::load(bytes, &pdfrum_parser::LoadOptions::default())?;
613    /// # let loaded = doc.page(0)?;
614    /// # let (limits, mut diags) = (Limits::default(), Diagnostics::default());
615    /// # let mut content = Vec::new();
616    /// # if let Some(contents) = loaded.dict.get(&Name::from("Contents"), &doc)
617    /// #     && let Some(Object::Stream(stream)) = contents.as_direct() {
618    /// #     content.extend_from_slice(
619    /// #         &pdfrum_filters::decode_chain(stream, 0, &doc, &limits, &mut diags).data);
620    /// # }
621    /// # let ops = parse_content(&content, &limits, &mut diags);
622    /// # let resources = Resources::for_page(
623    /// #     loaded.inherited(&Name::from("Resources"), &doc)
624    /// #         .and_then(|o| o.resolve(&doc).ok()?.as_dict().cloned()));
625    /// # let built = build_page_from_dict(&ops, &loaded.dict, |k| loaded.inherited(k, &doc),
626    /// #     &resources, &doc, &mut BuildContext::default(), &limits, &mut diags);
627    /// # let page = pdfrum_text::extract(&built, &doc, &pdfrum_text::ExtractOptions::default(),
628    /// #     &limits, &mut diags);
629    /// let first = page.char(CharIndex::new(0))?;
630    /// assert_eq!(char::from_u32(first.unicode), Some('H'));
631    /// assert_eq!(first.char_type, CharType::Normal);
632    /// assert_eq!(first.font_size, 12.0);
633    ///
634    /// // The error names both the index and the bound it broke.
635    /// assert_eq!(
636    ///     page.char(CharIndex::new(999)),
637    ///     Err(Error::CharIndexOutOfRange { index: CharIndex::new(999), len: 30 }),
638    /// );
639    /// # Ok::<(), Box<dyn std::error::Error>>(())
640    /// ```
641    pub fn char(&self, index: CharIndex) -> Result<&CharBox, Error> {
642        self.chars
643            .get(index.get())
644            .ok_or(Error::CharIndexOutOfRange {
645                index,
646                len: self.chars.len(),
647            })
648    }
649
650    /// The base font name of the font the character at `index` was drawn
651    /// with, as the font crate normalized it — the subset tag stripped and a
652    /// standard-14 alias canonicalized, so `ABCDEF+Arial,Bold` reads as
653    /// `Helvetica-Bold`.
654    ///
655    /// `None` past the end, for a character no text object drew (every
656    /// generated one), and for a font with no base name (Type 3).
657    /// # Examples
658    ///
659    /// ```
660    /// # use pdfrum_text::CharIndex;
661    /// # use pdfrum_common::{Diagnostics, Limits};
662    /// # use pdfrum_object::{Name, Object};
663    /// # use pdfrum_page::{BuildContext, Resources, build_page_from_dict, parse_content};
664    /// # use std::sync::Arc;
665    /// # let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
666    /// # let doc = pdfrum_parser::load(bytes, &pdfrum_parser::LoadOptions::default())?;
667    /// # let loaded = doc.page(0)?;
668    /// # let (limits, mut diags) = (Limits::default(), Diagnostics::default());
669    /// # let mut content = Vec::new();
670    /// # if let Some(contents) = loaded.dict.get(&Name::from("Contents"), &doc)
671    /// #     && let Some(Object::Stream(stream)) = contents.as_direct() {
672    /// #     content.extend_from_slice(
673    /// #         &pdfrum_filters::decode_chain(stream, 0, &doc, &limits, &mut diags).data);
674    /// # }
675    /// # let ops = parse_content(&content, &limits, &mut diags);
676    /// # let resources = Resources::for_page(
677    /// #     loaded.inherited(&Name::from("Resources"), &doc)
678    /// #         .and_then(|o| o.resolve(&doc).ok()?.as_dict().cloned()));
679    /// # let built = build_page_from_dict(&ops, &loaded.dict, |k| loaded.inherited(k, &doc),
680    /// #     &resources, &doc, &mut BuildContext::default(), &limits, &mut diags);
681    /// # let page = pdfrum_text::extract(&built, &doc, &pdfrum_text::ExtractOptions::default(),
682    /// #     &limits, &mut diags);
683    /// // Two lines, two fonts.
684    /// assert_eq!(page.font_name(CharIndex::new(0)), Some("Times-Roman"));
685    /// assert_eq!(page.font_name(CharIndex::new(15)), Some("Helvetica"));
686    ///
687    /// // Character 13 is the line break the extractor generated: no text
688    /// // object drew it, so it has no font.
689    /// assert_eq!(page.font_name(CharIndex::new(13)), None);
690    /// # Ok::<(), Box<dyn std::error::Error>>(())
691    /// ```
692    #[must_use]
693    pub fn font_name(&self, index: CharIndex) -> Option<&str> {
694        let object = self.chars.get(index.get())?.object?;
695        self.fonts.get(&object).map(String::as_str)
696    }
697}
698
699/// The words a page draws, in content order — the answer
700/// `Doc.getPageNumWords` counts and `Doc.getPageNthWord` indexes into.
701///
702/// # A different reading of "word" from the extraction pipeline's
703///
704/// This walks the page's **text objects** and their raw character codes, and
705/// it does not go through [`TextPage`] at all. That is deliberate rather than
706/// an oversight: extraction reorders by reading order, suppresses duplicate
707/// overprinted objects, inserts generated spaces and newlines and normalizes
708/// what it emits, and every one of those would change the count. The
709/// scripting API's word list is defined on the content stream as written.
710///
711/// # What separates two words
712///
713/// A single rule, applied per character: a character is *word-continuing*
714/// when its first code unit is neither a space nor above `U+28FF`, and a run
715/// of those is one word. So a character at `U+2900` or beyond — CJK, most
716/// symbols — is a word of its own, and `Hello, world!` is **two** words
717/// rather than four, because the comma and the exclamation mark are below the
718/// threshold and continue the run.
719///
720/// A character whose font maps it to nothing contributes `U+0000`, which is
721/// word-continuing; a space ends a word without starting one.
722/// # Examples
723///
724/// Each text object restarts the run, so the two lines of the fixture below
725/// are four words rather than two — and the comma stays attached, because it
726/// is below the word-continuing cutoff:
727///
728/// ```
729/// # use pdfrum_common::{Diagnostics, Limits};
730/// # use pdfrum_object::{Name, Object};
731/// # use pdfrum_page::{BuildContext, Resources, build_page_from_dict, parse_content};
732/// # use std::sync::Arc;
733/// # let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
734/// # let doc = pdfrum_parser::load(bytes, &pdfrum_parser::LoadOptions::default())?;
735/// # let loaded = doc.page(0)?;
736/// # let (limits, mut diags) = (Limits::default(), Diagnostics::default());
737/// # let mut content = Vec::new();
738/// # if let Some(contents) = loaded.dict.get(&Name::from("Contents"), &doc)
739/// #     && let Some(Object::Stream(stream)) = contents.as_direct() {
740/// #     content.extend_from_slice(
741/// #         &pdfrum_filters::decode_chain(stream, 0, &doc, &limits, &mut diags).data);
742/// # }
743/// # let ops = parse_content(&content, &limits, &mut diags);
744/// # let resources = Resources::for_page(
745/// #     loaded.inherited(&Name::from("Resources"), &doc)
746/// #         .and_then(|o| o.resolve(&doc).ok()?.as_dict().cloned()));
747/// # let built = build_page_from_dict(&ops, &loaded.dict, |k| loaded.inherited(k, &doc),
748/// #     &resources, &doc, &mut BuildContext::default(), &limits, &mut diags);
749/// assert_eq!(
750///     pdfrum_text::words(&built),
751///     ["Hello, ", "world!", "Goodbye, ", "world!"],
752/// );
753/// # Ok::<(), Box<dyn std::error::Error>>(())
754/// ```
755#[must_use]
756pub fn words(page: &Page) -> Vec<String> {
757    /// `IsLatinWord`: neither a space nor past the cutoff.
758    fn continues_a_word(unicode: u32) -> bool {
759        unicode != 0x20 && unicode <= 0x28FF
760    }
761
762    let mut out: Vec<String> = Vec::new();
763    for run in object::walk(&page.objects) {
764        // Each object restarts the run state, which is what makes a word
765        // split across two text objects two words.
766        let mut in_word = false;
767        for item in &run.items {
768            let mapped = run.font.unicode_from_charcode(item.code);
769            // `WideString::Front()` on an empty string is `0`, and zero
770            // continues a word.
771            let unicode = mapped.first().map_or(0, |ch| *ch as u32);
772            let continues = continues_a_word(unicode);
773            if !continues || !in_word {
774                in_word = continues;
775                if unicode != 0x20 {
776                    out.push(String::new());
777                }
778            }
779            if let Some(word) = out.last_mut()
780                && let Some(ch) = char::from_u32(unicode)
781            {
782                word.push(ch);
783            }
784        }
785    }
786    out
787}
788
789/// Formats the page as its [`search_text`](TextPage::search_text) — **not**
790/// the [`chars`](TextPage::chars) stream, which holds the control characters
791/// and placeholders this drops.
792impl std::fmt::Display for TextPage {
793    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        for ch in &self.search_text {
795            write!(f, "{ch}")?;
796        }
797        Ok(())
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    // Test fixtures compare floats exactly where the behaviour being pinned
804    // is exact.
805    #![allow(
806        clippy::float_cmp,
807        clippy::indexing_slicing,
808        reason = "test fixtures pin exact values"
809    )]
810
811    use super::*;
812
813    #[test]
814    fn an_empty_page_extracts_to_nothing() {
815        let page = TextPage::default();
816        assert_eq!(page.char_count(), 0);
817        assert_eq!(page.to_string(), "");
818        assert!(page.web_links().is_empty());
819        assert!(page.rects(..).is_empty());
820        assert_eq!(page.slice(..), "");
821    }
822
823    #[test]
824    fn char_names_the_bound_it_broke() {
825        let page = TextPage::default();
826        assert_eq!(
827            page.char(CharIndex::new(3)),
828            Err(Error::CharIndexOutOfRange {
829                index: CharIndex::new(3),
830                len: 0
831            })
832        );
833    }
834
835    #[test]
836    fn a_zero_size_page_gets_the_zero_display_matrix() {
837        let mut page = Page::empty();
838        page.crop_box = Rect::ZERO;
839        assert_eq!(display_matrix(&page).as_coeffs(), [0.0; 6]);
840    }
841
842    #[test]
843    fn an_ordinary_page_gets_a_y_flip() {
844        let page = Page::empty();
845        let matrix = display_matrix(&page);
846        // The bottom-left of the crop box maps to the top-left of the device.
847        let bottom_left = matrix * Point::new(0.0, 0.0);
848        assert!((bottom_left.y - 792.0).abs() < 1e-6, "{bottom_left:?}");
849        let top_left = matrix * Point::new(0.0, 792.0);
850        assert!(top_left.y.abs() < 1e-6, "{top_left:?}");
851    }
852
853    #[test]
854    fn the_public_types_are_send_and_sync() {
855        fn assert_send_sync<T: Send + Sync>() {}
856        assert_send_sync::<TextPage>();
857        assert_send_sync::<CharBox>();
858        assert_send_sync::<WebLink>();
859        assert_send_sync::<Error>();
860        assert_send_sync::<IndexMap>();
861    }
862}