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}