#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![warn(clippy::indexing_slicing)]
mod bidi;
mod charinfo;
mod dedup;
mod error;
mod find;
mod index;
mod line;
mod links;
mod object;
mod orientation;
mod pipeline;
mod select;
mod unicode;
mod word;
pub use charinfo::{CharBox, CharType, ObjectIndex};
pub use error::Error;
pub use find::FindOptions;
pub use index::{CharIndex, CharSegment, IndexMap, TextIndex};
pub use links::WebLink;
pub use object::TextRun;
pub use orientation::Orientation;
pub use word::Word;
#[doc(hidden)]
pub use links::{FoundLink, check_mail_link, check_web_link};
use kurbo::{Affine, Point, Rect, Size};
use pdfrum_common::{DiagKind, Diagnostics, Limits, Operation, Severity};
use pdfrum_object::Resolve;
use pdfrum_page::Page;
use std::collections::BTreeMap;
use std::ops::{Range, RangeBounds};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ExtractOptions {
pub rtl: bool,
}
#[derive(Debug, Clone, Default)]
pub struct TextPage {
pub chars: Vec<CharBox>,
pub search_text: Vec<char>,
pub runs: IndexMap,
pub fonts: BTreeMap<ObjectIndex, String>,
}
#[must_use]
pub fn extract<R: Resolve>(
page: &Page,
resolver: &R,
options: &ExtractOptions,
limits: &Limits,
diags: &mut Diagnostics,
) -> TextPage {
if limits.check_deadline(Operation::Extract).is_err() {
diags.record(Severity::Suspicious, DiagKind::TimeLimitReached, None);
return TextPage::default();
}
if page.objects.is_empty() {
return TextPage::default();
}
let runs = object::walk(&page.objects);
let page_flow = orientation::page_flow(page, &runs);
let display = display_matrix(page);
let mut builder = pipeline::Builder::new(&runs, page_flow, display, options.rtl, resolver);
let mut offered: Vec<TextRun> = Vec::new();
for (index, run) in runs.iter().enumerate() {
if dedup::repeats_a_predecessor(run, &offered, &builder.out.chars) {
diags.record(
pdfrum_common::Severity::Recovered,
pdfrum_common::DiagKind::TextObjectDuplicate,
None,
);
continue;
}
offered.push(run.clone());
builder.offer(index, diags);
}
builder.flush(diags);
builder.close_line();
let out = builder.out;
let text: Vec<char> = out
.text
.iter()
.filter_map(|unit| char::from_u32(*unit))
.collect();
let fonts = runs
.iter()
.filter(|run| !run.font.base_font_name().is_empty())
.map(|run| {
let name = String::from_utf8_lossy(run.font.base_font_name()).into_owned();
(run.index, name)
})
.collect();
let runs = index::build(&out.chars);
TextPage {
chars: out.chars,
search_text: text,
runs,
fonts,
}
}
fn display_matrix(page: &Page) -> Affine {
let (width, height) = page.display_size();
if width <= 0.0 || height <= 0.0 {
return Affine::new([0.0; 6]);
}
let normalizer = page.rotate.display_matrix(page.crop_box);
Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, height]) * normalizer
}
fn char_bounds(range: &impl RangeBounds<CharIndex>, total: usize) -> (usize, usize) {
use std::ops::Bound;
let start = match range.start_bound() {
Bound::Included(at) => at.get(),
Bound::Excluded(at) => at.get().saturating_add(1),
Bound::Unbounded => 0,
};
let end = match range.end_bound() {
Bound::Included(at) => at.get().saturating_add(1),
Bound::Excluded(at) => at.get(),
Bound::Unbounded => total,
};
(start, end.min(total))
}
impl TextPage {
#[must_use]
pub fn char_count(&self) -> usize {
self.chars.len()
}
#[must_use]
pub fn slice(&self, range: impl RangeBounds<CharIndex>) -> String {
let total = self.chars.len();
let (start, end) = char_bounds(&range, total);
if start >= end || start >= total || self.search_text.is_empty() {
return String::new();
}
let Some(text_start) = self.runs.text_index_at_or_after(CharIndex::new(start)) else {
return String::new();
};
let text_end = self.runs.text_index_end(CharIndex::new(end - 1));
if text_end <= text_start {
return String::new();
}
self.search_text
.get(text_start.get()..text_end.get())
.unwrap_or_default()
.iter()
.collect()
}
pub fn find<'a>(
&'a self,
needle: &str,
options: FindOptions,
) -> impl Iterator<Item = Range<TextIndex>> + 'a {
find::search(&self.to_string(), needle, options)
}
#[must_use]
pub fn web_links(&self) -> Vec<WebLink> {
links::extract(&self.chars, &self.search_text, &index::build(&self.chars))
}
#[must_use]
pub fn rects(&self, range: impl RangeBounds<CharIndex>) -> Vec<Rect> {
select::rects(&self.chars, range)
}
#[must_use]
pub fn index_at(&self, point: Point, tolerance: Size) -> Option<CharIndex> {
select::index_at(&self.chars, point, tolerance)
}
#[must_use]
pub fn text_in_rect(&self, rect: Rect) -> String {
select::text_in_rect(&self.chars, rect)
}
#[must_use]
pub fn text_of_object(&self, object: ObjectIndex) -> String {
select::text_of_object(&self.chars, object)
}
pub fn char(&self, index: CharIndex) -> Result<&CharBox, Error> {
self.chars
.get(index.get())
.ok_or(Error::CharIndexOutOfRange {
index,
len: self.chars.len(),
})
}
#[must_use]
pub fn font_name(&self, index: CharIndex) -> Option<&str> {
let object = self.chars.get(index.get())?.object?;
self.fonts.get(&object).map(String::as_str)
}
}
#[must_use]
pub fn words(page: &Page) -> Vec<String> {
fn continues_a_word(unicode: u32) -> bool {
unicode != 0x20 && unicode <= 0x28FF
}
let mut out: Vec<String> = Vec::new();
for run in object::walk(&page.objects) {
let mut in_word = false;
for item in &run.items {
let mapped = run.font.unicode_from_charcode(item.code);
let unicode = mapped.first().map_or(0, |ch| *ch as u32);
let continues = continues_a_word(unicode);
if !continues || !in_word {
in_word = continues;
if unicode != 0x20 {
out.push(String::new());
}
}
if let Some(word) = out.last_mut()
&& let Some(ch) = char::from_u32(unicode)
{
word.push(ch);
}
}
}
out
}
impl std::fmt::Display for TextPage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for ch in &self.search_text {
write!(f, "{ch}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
clippy::indexing_slicing,
reason = "test fixtures pin exact values"
)]
use super::*;
#[test]
fn an_empty_page_extracts_to_nothing() {
let page = TextPage::default();
assert_eq!(page.char_count(), 0);
assert_eq!(page.to_string(), "");
assert!(page.web_links().is_empty());
assert!(page.rects(..).is_empty());
assert_eq!(page.slice(..), "");
}
#[test]
fn char_names_the_bound_it_broke() {
let page = TextPage::default();
assert_eq!(
page.char(CharIndex::new(3)),
Err(Error::CharIndexOutOfRange {
index: CharIndex::new(3),
len: 0
})
);
}
#[test]
fn a_zero_size_page_gets_the_zero_display_matrix() {
let mut page = Page::empty();
page.crop_box = Rect::ZERO;
assert_eq!(display_matrix(&page).as_coeffs(), [0.0; 6]);
}
#[test]
fn an_ordinary_page_gets_a_y_flip() {
let page = Page::empty();
let matrix = display_matrix(&page);
let bottom_left = matrix * Point::new(0.0, 0.0);
assert!((bottom_left.y - 792.0).abs() < 1e-6, "{bottom_left:?}");
let top_left = matrix * Point::new(0.0, 792.0);
assert!(top_left.y.abs() < 1e-6, "{top_left:?}");
}
#[test]
fn the_public_types_are_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<TextPage>();
assert_send_sync::<CharBox>();
assert_send_sync::<WebLink>();
assert_send_sync::<Error>();
assert_send_sync::<IndexMap>();
}
}