use crate::encryption::EncryptionHandler;
use crate::error::{Error, Result};
use crate::layout::TextSpan;
use crate::object::{Object, ObjectRef};
use crate::parser::parse_object;
use crate::parser_config::ParserOptions;
use crate::xref::{CrossRefTable, find_xref_offset, parse_xref};
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::{BufRead, BufReader, Cursor, Read, Seek, SeekFrom};
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
pub(crate) use crate::cache::MutexExt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReadingOrder {
#[default]
TopToBottom,
ColumnAware,
Structure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageRotation {
Absent,
Valid(i32),
Malformed,
}
enum PdfReader {
Memory(BufReader<Cursor<Vec<u8>>>),
}
impl Read for PdfReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
PdfReader::Memory(r) => r.read(buf),
}
}
}
impl Seek for PdfReader {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
match self {
PdfReader::Memory(r) => r.seek(pos),
}
}
}
impl BufRead for PdfReader {
fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
match self {
PdfReader::Memory(r) => r.fill_buf(),
}
}
fn consume(&mut self, amt: usize) {
match self {
PdfReader::Memory(r) => r.consume(amt),
}
}
}
const MAX_RECURSION_DEPTH: u32 = 100;
const MAX_PAGE_TREE_DEPTH: u32 = 256;
#[derive(Debug, Clone)]
pub struct PageInfo {
pub media_box: crate::geometry::Rect,
pub crop_box: Option<crate::geometry::Rect>,
pub rotation: i32,
}
const DEFAULT_OBJECT_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
const DEFAULT_XOBJECT_CACHE_MAX_ENTRIES: usize = 1024;
const DEFAULT_OBJECT_STREAM_CACHE_MAX_BYTES: usize = DEFAULT_OBJECT_CACHE_MAX_BYTES / 4;
const DEFAULT_OBJECT_STREAM_RECOVERY_MARKERS: usize = 4096;
const FORWARD_GAP_K: f32 = 1.25;
const SAME_LINE_REORDER_MAX_GAP_FACTOR: f32 = 3.0;
pub(crate) use crate::cache::BoundedEntryCache;
struct BoundedObjectCache {
map: HashMap<ObjectRef, Object>,
insertion_order: std::collections::VecDeque<ObjectRef>,
current_bytes: usize,
max_bytes: usize,
}
struct BoundedObjectStreamCache {
map: HashMap<ObjectRef, Arc<HashMap<u32, Object>>>,
insertion_order: std::collections::VecDeque<ObjectRef>,
current_bytes: usize,
max_bytes: usize,
}
struct BoundedRecoveryTelemetry {
seen: HashSet<u32>,
max_entries: usize,
saturated: bool,
}
impl BoundedRecoveryTelemetry {
fn new(max_entries: usize) -> Self {
Self {
seen: HashSet::new(),
max_entries,
saturated: false,
}
}
fn should_emit(&mut self, stream_object_id: u32) -> bool {
if self.saturated || self.seen.contains(&stream_object_id) {
return false;
}
if self.seen.len() >= self.max_entries {
self.seen.clear();
self.saturated = true;
return false;
}
self.seen.insert(stream_object_id)
}
}
impl BoundedObjectStreamCache {
fn checked_capacity_bytes(capacity: usize, element_size: usize) -> Option<usize> {
capacity.checked_mul(element_size)
}
fn new(max_bytes: usize) -> Self {
Self {
map: HashMap::new(),
insertion_order: std::collections::VecDeque::new(),
current_bytes: 0,
max_bytes,
}
}
fn get(&self, key: &ObjectRef) -> Option<&Arc<HashMap<u32, Object>>> {
self.map.get(key)
}
fn insert(&mut self, key: ObjectRef, value: Arc<HashMap<u32, Object>>) -> bool {
let Some(entry_size) = Self::estimate_size(&value) else {
return false;
};
if entry_size > self.max_bytes {
return false;
}
self.remove(&key);
while self.current_bytes.saturating_add(entry_size) > self.max_bytes {
let Some(old_key) = self.insertion_order.pop_front() else {
return false;
};
self.remove(&old_key);
}
self.current_bytes = self.current_bytes.saturating_add(entry_size);
self.map.insert(key, value);
self.insertion_order.push_back(key);
true
}
fn remove(&mut self, key: &ObjectRef) {
if let Some(old_value) = self.map.remove(key) {
let old_size = Self::estimate_size(&old_value).unwrap_or(self.max_bytes);
self.current_bytes = self.current_bytes.saturating_sub(old_size);
}
if let Some(position) = self.insertion_order.iter().position(|candidate| candidate == key) {
self.insertion_order.remove(position);
}
}
fn estimate_size(objects: &HashMap<u32, Object>) -> Option<usize> {
let headers =
std::mem::size_of::<HashMap<u32, Object>>().checked_add(std::mem::size_of::<Arc<HashMap<u32, Object>>>())?;
let buckets = Self::checked_capacity_bytes(
objects.capacity(),
std::mem::size_of::<u32>() + std::mem::size_of::<Object>() + std::mem::size_of::<usize>(),
)?;
let mut total = headers.checked_add(buckets)?;
let mut stack: Vec<&Object> = objects.values().collect();
while let Some(object) = stack.pop() {
total = total.checked_add(Self::estimate_dynamic_size(object, &mut stack)?)?;
}
Some(total)
}
fn estimate_dynamic_size<'a>(object: &'a Object, stack: &mut Vec<&'a Object>) -> Option<usize> {
match object {
Object::String(value) => Some(value.capacity()),
Object::Name(value) => Some(value.capacity()),
Object::Array(values) => {
stack.extend(values);
Self::checked_capacity_bytes(values.capacity(), std::mem::size_of::<Object>())
}
Object::Dictionary(values) => Self::estimate_dictionary(values, stack),
Object::Stream { dict, data } => Self::estimate_dictionary(dict, stack)?.checked_add(data.len()),
_ => Some(0),
}
}
fn estimate_dictionary<'a>(values: &'a HashMap<String, Object>, stack: &mut Vec<&'a Object>) -> Option<usize> {
let buckets = Self::checked_capacity_bytes(
values.capacity(),
std::mem::size_of::<String>() + std::mem::size_of::<Object>() + std::mem::size_of::<usize>(),
)?;
let mut total = std::mem::size_of::<HashMap<String, Object>>().checked_add(buckets)?;
for (key, value) in values {
total = total.checked_add(key.capacity())?;
stack.push(value);
}
Some(total)
}
}
impl BoundedObjectCache {
fn new(max_bytes: usize) -> Self {
Self {
map: HashMap::new(),
insertion_order: std::collections::VecDeque::new(),
current_bytes: 0,
max_bytes,
}
}
fn get(&self, key: &ObjectRef) -> Option<&Object> {
self.map.get(key)
}
fn insert(&mut self, key: ObjectRef, value: Object) {
let entry_size = Self::estimate_size(&value);
if entry_size > self.max_bytes {
return;
}
if let Some(old_val) = self.map.get(&key) {
self.current_bytes = self.current_bytes.saturating_sub(Self::estimate_size(old_val));
}
let mut skipped_self = false;
while self.current_bytes + entry_size > self.max_bytes {
match self.insertion_order.pop_front() {
Some(old_key) => {
if old_key == key {
if skipped_self {
self.insertion_order.push_front(old_key);
break;
}
self.insertion_order.push_back(old_key);
skipped_self = true;
continue;
}
if let Some(old_val) = self.map.remove(&old_key) {
self.current_bytes = self.current_bytes.saturating_sub(Self::estimate_size(&old_val));
}
}
None => break,
}
}
if self.map.insert(key, value).is_none() {
self.insertion_order.push_back(key);
}
self.current_bytes += entry_size;
}
fn len(&self) -> usize {
self.map.len()
}
fn keys(&self) -> impl Iterator<Item = &ObjectRef> {
self.map.keys()
}
fn clear(&mut self) {
self.map.clear();
self.insertion_order.clear();
self.current_bytes = 0;
}
fn estimate_size(obj: &Object) -> usize {
Self::estimate_size_depth(obj, 8)
}
fn estimate_size_depth(obj: &Object, depth: u8) -> usize {
if depth == 0 {
return 64;
}
match obj {
Object::Stream { dict, data } => {
let dict_size: usize = dict
.iter()
.map(|(k, v)| k.len() + 32 + Self::estimate_size_depth(v, depth - 1))
.sum();
data.len() + dict_size + 64
}
Object::Dictionary(d) => {
let inner: usize = d
.iter()
.map(|(k, v)| k.len() + 32 + Self::estimate_size_depth(v, depth - 1))
.sum();
inner + 64
}
Object::Array(a) => {
let inner: usize = a.iter().map(|v| Self::estimate_size_depth(v, depth - 1)).sum();
inner + 64
}
Object::String(s) => s.len() + 32,
Object::Name(s) => s.len() + 32,
_ => 32,
}
}
}
thread_local! {
#[allow(clippy::missing_const_for_thread_local)]
static RESOLVING_STACK: RefCell<HashSet<ObjectRef>> = RefCell::new(HashSet::new());
static RECURSION_DEPTH: RefCell<u32> = const { RefCell::new(0) };
}
const FONT_IDENTITY_MAX_REFERENCE_DEPTH: usize = 32;
const FONT_IDENTITY_MAX_RESOLVED_REFERENCES: usize = 4096;
const FONT_IDENTITY_MAX_HASHED_BYTES: usize = 32 * 1024 * 1024;
const FONT_HASH_BYTE_LIMIT_MARKER: u8 = 247;
const FONT_HASH_REFERENCE_LIMIT_MARKER: u8 = 248;
const FONT_HASH_RESOLVED_REFERENCE_MARKER: u8 = 249;
const FONT_HASH_DEPTH_LIMIT_MARKER: u8 = 250;
const FONT_HASH_UNRESOLVED_REFERENCE_MARKER: u8 = 251;
const FONT_HASH_REFERENCE_CYCLE_MARKER: u8 = 252;
const FONT_IDENTITY_SEMANTIC_FIELDS: &[&str] = &[
"Encoding",
"ToUnicode",
"FontDescriptor",
"Widths",
"DescendantFonts",
"FontMatrix",
];
const FONT_IDENTITY_CONSUMED_DICTIONARY_FIELDS: &[&str] = &[
"Ascent",
"BaseEncoding",
"BitsPerComponent",
"BlackIs1",
"CIDSystemInfo",
"CIDToGIDMap",
"CMapName",
"ColorTransform",
"Colors",
"Columns",
"DW",
"DW2",
"DamagedRowsBeforeError",
"DecodeParms",
"Descent",
"Differences",
"EarlyChange",
"EncodedByteAlign",
"EndOfBlock",
"EndOfLine",
"Filter",
"Flags",
"FontDescriptor",
"FontFile",
"FontFile2",
"FontFile3",
"FontWeight",
"JBIG2Globals",
"K",
"Name",
"Ordering",
"Predictor",
"Registry",
"StemV",
"Subtype",
"Supplement",
"W",
"W2",
];
#[derive(Clone, Copy)]
struct FontIdentityHash {
value: u64,
cacheable: bool,
subtree_depth: usize,
}
#[derive(Default)]
struct FontHashTraversal {
resolving: HashSet<ObjectRef>,
resolved_hashes: HashMap<ObjectRef, FontIdentityHash>,
resolved_references: usize,
depth_frames: Vec<FontHashDepthFrame>,
}
struct FontHashDepthFrame {
entry_depth: usize,
max_depth: usize,
}
impl FontHashTraversal {
fn begin_reference(&mut self, entry_depth: usize) {
self.depth_frames.push(FontHashDepthFrame {
entry_depth,
max_depth: entry_depth,
});
}
fn observe_depth(&mut self, depth: usize) {
for frame in &mut self.depth_frames {
frame.max_depth = frame.max_depth.max(depth);
}
}
fn end_reference(&mut self) -> usize {
let Some(frame) = self.depth_frames.pop() else {
return FONT_IDENTITY_MAX_REFERENCE_DEPTH;
};
frame.max_depth.saturating_sub(frame.entry_depth)
}
}
pub struct PdfDocument {
pub source_bytes: Vec<u8>,
version: (u8, u8),
xref: CrossRefTable,
trailer: Object,
object_cache: Mutex<BoundedObjectCache>,
object_stream_cache: Mutex<BoundedObjectStreamCache>,
object_stream_telemetry_seen: Mutex<BoundedRecoveryTelemetry>,
encryption_handler: Mutex<Option<EncryptionHandler>>,
encrypt_dict_ref: Mutex<Option<ObjectRef>>,
#[allow(dead_code)]
options: ParserOptions,
#[allow(dead_code)]
header_offset: u64,
font_cache: Mutex<BoundedEntryCache<ObjectRef, Arc<crate::fonts::FontInfo>>>,
font_set_cache: Mutex<BoundedEntryCache<ObjectRef, Vec<(String, Arc<crate::fonts::FontInfo>)>>>,
font_fingerprint_cache: Mutex<BoundedEntryCache<u64, Vec<(String, Arc<crate::fonts::FontInfo>)>>>,
font_name_set_cache: Mutex<BoundedEntryCache<u64, (Arc<Vec<(String, Arc<crate::fonts::FontInfo>)>>, u64)>>,
font_set_donated_cache: Mutex<BoundedEntryCache<ObjectRef, Vec<(String, Arc<crate::fonts::FontInfo>)>>>,
font_fingerprint_donated_cache: Mutex<BoundedEntryCache<u64, Vec<(String, Arc<crate::fonts::FontInfo>)>>>,
font_name_set_donated_cache: Mutex<BoundedEntryCache<u64, Arc<Vec<(String, Arc<crate::fonts::FontInfo>)>>>>,
#[cfg(test)]
donation_call_count: AtomicUsize,
#[cfg(test)]
donation_apply_count: AtomicUsize,
font_identity_cache: Mutex<BoundedEntryCache<u64, Arc<crate::fonts::FontInfo>>>,
font_id_hash_cache: Mutex<HashMap<ObjectRef, Option<u64>>>,
font_reference_hash_cache: Mutex<BoundedEntryCache<ObjectRef, FontIdentityHash>>,
font_identity_hashed_bytes: AtomicUsize,
font_identity_shared_cache_enabled: AtomicBool,
structure_tree_cache: Mutex<Option<Option<Arc<crate::structure::StructTreeRoot>>>>,
structure_content_cache: Mutex<Option<HashMap<u32, Vec<crate::structure::OrderedContent>>>>,
actualtext_index_cache: Mutex<Option<Option<Arc<crate::structure::ActualTextIndex>>>>,
pub(crate) mc_actualtext_mcids: Mutex<HashMap<usize, HashSet<u32>>>,
table_elements_cache: Mutex<Option<HashMap<u32, Vec<crate::structure::StructElem>>>>,
page_cache: Mutex<HashMap<usize, Object>>,
page_cache_populated: AtomicBool,
scanned_object_offsets: Mutex<Option<HashMap<u32, u64>>>,
objstm_recovery_done: Mutex<bool>,
xref_reconstructed: bool,
image_xobject_cache: Mutex<HashSet<ObjectRef>>,
pub(crate) xobject_text_free_cache: Mutex<HashSet<ObjectRef>>,
pub(crate) xobject_stream_cache: Mutex<HashMap<ObjectRef, std::sync::Arc<Vec<u8>>>>,
pub(crate) xobject_stream_cache_bytes: AtomicUsize,
pub(crate) xobject_spans_cache:
Mutex<BoundedEntryCache<(ObjectRef, [i64; 6]), Option<Vec<crate::layout::TextSpan>>>>,
pub(crate) form_xobject_images_cache: Mutex<BoundedEntryCache<ObjectRef, Vec<crate::extractors::PdfImage>>>,
pub(crate) erase_regions: Mutex<HashMap<usize, Vec<crate::geometry::Rect>>>,
page_content_cache: Mutex<BoundedEntryCache<usize, std::sync::Arc<Vec<u8>>>>,
page_spans_cache: Mutex<BoundedEntryCache<usize, std::sync::Arc<Vec<crate::layout::TextSpan>>>>,
search_index: Mutex<HashMap<usize, std::sync::Arc<crate::search::SearchPageIndex>>>,
page_chars_cache: Mutex<BoundedEntryCache<usize, std::sync::Arc<Vec<crate::layout::TextChar>>>>,
running_artifact_signatures: Mutex<Option<std::sync::Arc<std::collections::HashMap<String, usize>>>>,
article_threads_cache: Mutex<Option<std::sync::Arc<Vec<crate::structure::ArticleThread>>>>,
output_intent_cmyk_profile_cache: Mutex<Option<Option<std::sync::Arc<crate::color::IccProfile>>>>,
accumulated_warnings: Mutex<Vec<String>>,
warning_sink: crate::extractors::warnings::WarningSink,
recovery: std::sync::Arc<crate::extractors::recovery_tally::RecoveryCounts>,
}
pub(super) const LOG_TARGET: &str = module_path!();
impl PdfDocument {
pub(crate) fn recovery_counts(&self) -> &std::sync::Arc<crate::extractors::recovery_tally::RecoveryCounts> {
&self.recovery
}
}
impl Drop for PdfDocument {
fn drop(&mut self) {
crate::extractors::recovery_tally::report(&self.recovery.snapshot());
}
}
const _: () = {
fn _assert_send_sync<T: Send + Sync>() {}
fn _check() {
_assert_send_sync::<PdfDocument>();
}
};
impl std::fmt::Debug for PdfDocument {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PdfDocument")
.field("version", &self.version)
.field("xref_entries", &self.xref.len())
.field("cached_objects", &self.object_cache.lock_or_recover().len())
.finish_non_exhaustive()
}
}
struct ImageExtractFilter {
min_width: i64,
min_height: i64,
max_pixels: u64,
skip_indexed_small: i64,
}
impl Default for ImageExtractFilter {
fn default() -> Self {
Self {
min_width: 8,
min_height: 8,
max_pixels: u64::MAX,
skip_indexed_small: 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PageArea {
Header,
Footer,
}
fn find_objstm_candidates(content: &[u8]) -> Vec<(u32, u64)> {
const DICT_PEEK_BYTES: usize = 2048;
let mut out = Vec::new();
let mut pos = 0usize;
while pos < content.len() {
let valid_start =
pos == 0 || content[pos - 1] == b'\n' || content[pos - 1] == b'\r' || content[pos - 1] == b' ';
if !valid_start || !content[pos].is_ascii_digit() {
pos += 1;
continue;
}
let header_start = pos;
let num_start = pos;
while pos < content.len() && content[pos].is_ascii_digit() {
pos += 1;
}
if pos >= content.len() || content[pos] != b' ' {
pos = header_start + 1;
continue;
}
let obj_num: u32 = match std::str::from_utf8(&content[num_start..pos])
.ok()
.and_then(|s| s.parse().ok())
{
Some(n) => n,
None => {
pos = header_start + 1;
continue;
}
};
pos += 1;
let gen_start = pos;
while pos < content.len() && content[pos].is_ascii_digit() {
pos += 1;
}
if pos >= content.len() || content[pos] != b' ' {
pos = header_start + 1;
continue;
}
if std::str::from_utf8(&content[gen_start..pos])
.ok()
.and_then(|s| s.parse::<u32>().ok())
.is_none()
{
pos = header_start + 1;
continue;
}
pos += 1;
if pos + 3 > content.len() || &content[pos..pos + 3] != b"obj" {
pos = header_start + 1;
continue;
}
let window_end = (pos + DICT_PEEK_BYTES).min(content.len());
let window = &content[pos..window_end];
if contains_objstm_marker(window) {
out.push((obj_num, header_start as u64));
}
pos = header_start + 1;
}
out
}
fn contains_objstm_marker(window: &[u8]) -> bool {
let mut i = 0;
while i + 5 <= window.len() {
if &window[i..i + 5] == b"/Type" {
let mut j = i + 5;
while j < window.len()
&& (window[j] == b' ' || window[j] == b'\t' || window[j] == b'\r' || window[j] == b'\n')
{
j += 1;
}
if j + 7 <= window.len() && &window[j..j + 7] == b"/ObjStm" {
return true;
}
}
i += 1;
}
false
}
fn extract_inks_from_color_space_dict(
cs_dict: &std::collections::HashMap<String, Object>,
doc: Option<&PdfDocument>,
out: &mut Vec<String>,
) {
let mut visited: std::collections::HashSet<ObjectRef> = std::collections::HashSet::new();
for cs_def in cs_dict.values() {
collect_inks_from_color_space(cs_def, doc, out, &mut visited, 0);
}
}
fn collect_inks_from_color_space(
cs_def: &Object,
doc: Option<&PdfDocument>,
out: &mut Vec<String>,
visited: &mut std::collections::HashSet<ObjectRef>,
depth: u32,
) {
if depth >= MAX_RECURSION_DEPTH {
return;
}
let deref = |obj: &Object| -> Object {
match (obj.as_reference(), doc) {
(Some(r), Some(d)) => d.load_object(r).unwrap_or_else(|_| obj.clone()),
_ => obj.clone(),
}
};
let arr = match cs_def.as_array() {
Some(a) => a,
None => return,
};
if arr.len() < 2 {
return;
}
let cs_type = match arr.first().and_then(Object::as_name) {
Some(n) => n,
None => return,
};
match cs_type {
"Pattern" | "Indexed" => {
if let Some(r) = arr[1].as_reference()
&& !visited.insert(r)
{
return;
}
let underlying = deref(&arr[1]);
collect_inks_from_color_space(&underlying, doc, out, visited, depth + 1);
}
"Separation" => {
let name_obj = deref(&arr[1]);
if let Some(ink) = name_obj.as_name()
&& ink != "All"
&& ink != "None"
{
out.push(ink.to_string());
}
}
"DeviceN" => {
let names_obj = match arr.get(1) {
Some(o) => deref(o),
None => return,
};
let process_names: std::collections::HashSet<String> = arr
.get(4)
.map(&deref)
.as_ref()
.and_then(Object::as_dict)
.and_then(|attrs| attrs.get("Process"))
.map(&deref)
.as_ref()
.and_then(Object::as_dict)
.and_then(|proc_dict| proc_dict.get("Components"))
.map(&deref)
.as_ref()
.and_then(Object::as_array)
.map(|comps| comps.iter().filter_map(|o| o.as_name().map(str::to_string)).collect())
.unwrap_or_default();
if let Some(inks) = names_obj.as_array() {
for ink_obj in inks {
if let Some(ink) = ink_obj.as_name()
&& ink != "All"
&& ink != "None"
&& !process_names.contains(ink)
{
out.push(ink.to_string());
}
}
}
}
_ => {}
}
}
#[derive(Debug, Clone)]
pub(crate) enum ActualTextAction {
EmitAndSuppress(std::sync::Arc<str>),
Suppress,
}
fn trace_open_error(error: &Error) {
if let Some(error_offset) = error.telemetry_offset() {
tracing::error!(error_code = error.telemetry_code(), error_offset, "PDF open failed");
} else {
tracing::error!(error_code = error.telemetry_code(), "PDF open failed");
}
}
fn trace_xref_parse_failure(error: &Error) {
if let Some(error_offset) = error.telemetry_offset() {
tracing::warn!(
error_code = error.telemetry_code(),
error_offset,
"regular xref parsing failed; attempting reconstruction"
);
} else {
tracing::warn!(
error_code = error.telemetry_code(),
"regular xref parsing failed; attempting reconstruction"
);
}
}
fn trace_recoverable_pdf_error(operation: &'static str, error: &Error) {
crate::error::trace_recovery(operation, error);
}
fn trace_fatal_pdf_error(operation: &'static str, error: &Error) {
crate::error::trace_failure(operation, error);
}
fn resolve_encrypt_dictionary_references(
dictionary: &HashMap<String, Object>,
mut load: impl FnMut(ObjectRef) -> Result<Object>,
) -> HashMap<String, Object> {
let mut resolved_dictionary = dictionary.clone();
let mut unresolved_reference_count = 0usize;
for value in resolved_dictionary.values_mut() {
if let Object::Reference(object_reference) = value {
match load(*object_reference) {
Ok(resolved) => *value = resolved,
Err(_) => unresolved_reference_count += 1,
}
}
}
if unresolved_reference_count > 0 {
crate::error::trace_recovery_count(
"resolve_encrypt_reference",
"unresolved_reference",
unresolved_reference_count,
);
}
resolved_dictionary
}
impl PdfDocument {
const RTL_WORD_BOUNDARY: char = '\u{F8FF}';
pub fn warnings(&self) -> Vec<String> {
self.accumulated_warnings.lock_or_recover().clone()
}
pub fn take_warnings(&self) -> Vec<String> {
std::mem::take(&mut *self.accumulated_warnings.lock_or_recover())
}
pub(crate) fn push_warning(&self, msg: impl Into<String>) {
self.accumulated_warnings.lock_or_recover().push(msg.into());
}
pub fn structured_warnings(&self) -> Vec<crate::extractors::warnings::Warning> {
let global = crate::extractors::warnings::drain_global_warnings();
if !global.is_empty() {
self.warning_sink.extend(global);
}
self.warning_sink.snapshot()
}
pub fn take_structured_warnings(&self) -> Vec<crate::extractors::warnings::Warning> {
self.warning_sink.take()
}
pub fn push_structured_warning(&self, warning: crate::extractors::warnings::Warning) {
self.warning_sink.push(warning);
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExtractedImageRef {
pub filename: String,
pub format: ImageFormat,
pub width: u32,
pub height: u32,
pub bbox: Option<crate::geometry::Rect>,
pub rotation: i32,
pub matrix: [f32; 6],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFormat {
Png,
Jpeg,
}
fn get_root_ref_from_trailer(trailer: &Object) -> Option<ObjectRef> {
trailer.as_dict()?.get("Root")?.as_reference()
}
fn first_in_use_uncompressed(xref: &crate::xref::CrossRefTable) -> Option<ObjectRef> {
xref.all_object_numbers()
.filter_map(|n| xref.get(n).map(|e| (n, e)))
.find(|(_, e)| e.in_use && e.entry_type == crate::xref::XRefEntryType::Uncompressed)
.map(|(n, e)| ObjectRef::new(n, e.generation))
}
fn looks_like_prose_table(table: &crate::structure::Table) -> bool {
let mut total = 0usize;
let mut sentence_tails = 0usize;
let mut lower_starts = 0usize;
let mut leader_dots = 0usize;
for row in &table.rows {
for cell in &row.cells {
let trimmed = cell.text.trim();
if trimmed.is_empty() {
continue;
}
total += 1;
if let Some(last) = trimmed.chars().last()
&& matches!(last, ',' | ';')
{
sentence_tails += 1;
}
if let Some(first) = trimmed.chars().next()
&& first.is_ascii_lowercase()
{
lower_starts += 1;
}
if trimmed.chars().all(|c| c == '.' || c == ' ') {
leader_dots += 1;
}
}
}
if total < 10 {
return false;
}
let tail_ratio = sentence_tails as f32 / total as f32;
let lower_ratio = lower_starts as f32 / total as f32;
let leader_ratio = leader_dots as f32 / total as f32;
tail_ratio > 0.12 || lower_ratio > 0.25 || leader_ratio > 0.10
}
fn validate_object_at_offset<R: Read + Seek>(
reader: &mut R,
xref: &crate::xref::CrossRefTable,
obj_ref: ObjectRef,
) -> bool {
let entry = match xref.get(obj_ref.id) {
Some(e) => e,
None => return false,
};
if entry.entry_type == crate::xref::XRefEntryType::Compressed {
return true;
}
if reader.seek(SeekFrom::Start(entry.offset)).is_err() {
return false;
}
let mut buf = [0u8; 32];
let n = reader.read(&mut buf).unwrap_or(0);
if n == 0 {
return false;
}
let s = String::from_utf8_lossy(&buf[..n]);
let mut parts = s.split_whitespace();
let first_is_num = parts.next().is_some_and(|t| t.parse::<u32>().is_ok());
let second_is_num = parts.next().is_some_and(|t| t.parse::<u16>().is_ok());
let third_is_obj = parts.next().is_some_and(|t| t == "obj" || t.starts_with("obj"));
first_is_num && second_is_num && third_is_obj
}
fn validate_root_loadable<R: Read + Seek>(reader: &mut R, xref: &crate::xref::CrossRefTable, trailer: &Object) -> bool {
let root_ref = match get_root_ref_from_trailer(trailer) {
Some(r) => r,
None => return false,
};
validate_object_at_offset(reader, xref, root_ref)
}
fn has_standalone_obj_keyword(s: &[u8]) -> bool {
for (i, window) in s.windows(3).enumerate() {
if window != b"obj" {
continue;
}
if i >= 3 && &s[i - 3..i] == b"end" {
continue;
}
if i == 0 || s[i - 1].is_ascii_whitespace() || s[i - 1].is_ascii_digit() {
return true;
}
}
false
}
pub fn parse_header<R: Read + Seek>(reader: &mut R, lenient: bool) -> Result<(u8, u8, u64)> {
let start_pos = reader.stream_position().unwrap_or(0);
let mut header = [0u8; 8];
let strict_read_ok = match reader.read_exact(&mut header) {
Ok(_) => {
if &header[0..5] == b"%PDF-" {
return parse_version_from_header(&header, lenient).map(|(major, minor)| (major, minor, 0));
}
true
}
Err(e) => {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
if !lenient {
return Err(Error::InvalidHeader(
"File too short for PDF header (expected at least 8 bytes)".to_string(),
));
}
false
} else {
return Err(Error::InvalidHeader(format!("Failed to read file: {}", e)));
}
}
};
if !lenient && strict_read_ok {
return Err(Error::InvalidHeader(format!(
"Expected '%PDF-' at byte 0, found '{}'",
String::from_utf8_lossy(&header[0..5])
)));
}
reader.seek(SeekFrom::Start(start_pos))?;
let mut buffer = vec![0u8; 8192];
let bytes_read = match reader.read(&mut buffer) {
Ok(0) => return Err(Error::InvalidHeader("File is empty (0 bytes read)".to_string())),
Ok(n) => n,
Err(e) => {
return Err(Error::InvalidHeader(format!(
"I/O error while searching for PDF header: {}",
e
)));
}
};
buffer.truncate(bytes_read);
match find_substring(&buffer, b"%PDF-") {
Some(offset) => {
if offset + 8 > buffer.len() {
return Err(Error::InvalidHeader(
"PDF header found but insufficient bytes for version".to_string(),
));
}
let header_bytes = &buffer[offset..offset + 8];
let mut header_arr = [0u8; 8];
header_arr.copy_from_slice(header_bytes);
let (major, minor) = parse_version_from_header(&header_arr, true)?;
let header_start = start_pos + offset as u64;
let after_header = header_start + 8;
reader.seek(SeekFrom::Start(after_header))?;
Ok((major, minor, header_start))
}
None => {
if lenient {
tracing::warn!(
operation = "parse_pdf_header",
reason = "missing_header",
"PDF header recovery defaulted to version 1.4"
);
reader.seek(SeekFrom::Start(0))?;
Ok((1, 4, 0))
} else {
Err(Error::InvalidHeader(
"No PDF header found in first 8192 bytes of file".to_string(),
))
}
}
}
}
fn parse_version_from_header(header: &[u8; 8], lenient: bool) -> Result<(u8, u8)> {
if &header[0..5] != b"%PDF-" {
return Err(Error::InvalidHeader(format!(
"Expected '%PDF-', found '{}'",
String::from_utf8_lossy(&header[0..5])
)));
}
if header[6] != b'.' {
if lenient {
tracing::warn!(
operation = "parse_pdf_version",
reason = "invalid_version_separator",
"Malformed PDF version; defaulting to 1.4"
);
return Ok((1, 4));
}
return Err(Error::InvalidHeader(format!(
"Invalid version format: expected '.', found '{}'",
header[6] as char
)));
}
let major = header[5];
let minor = header[7];
if !major.is_ascii_digit() || !minor.is_ascii_digit() {
if lenient {
tracing::warn!(
operation = "parse_pdf_version",
reason = "non_numeric_version",
"Malformed PDF version; defaulting to 1.4"
);
return Ok((1, 4));
}
return Err(Error::InvalidHeader(format!(
"Invalid version: {}.{} (not digits)",
major as char, minor as char
)));
}
let major = major - b'0';
let minor = minor - b'0';
if major > 2 || (major == 0 && minor == 0) {
if lenient {
tracing::warn!(
operation = "parse_pdf_version",
reason = "unsupported_version",
version_major = major,
version_minor = minor,
"Unsupported PDF version; defaulting to 1.4"
);
return Ok((1, 4));
}
return Err(Error::UnsupportedVersion(format!("{}.{}", major, minor)));
}
Ok((major, minor))
}
pub fn parse_trailer<R: Read>(reader: &mut R) -> Result<Object> {
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer)?;
let content = String::from_utf8_lossy(&buffer);
let trailer_pos = content
.find("trailer")
.ok_or_else(|| Error::InvalidPdf("Trailer keyword not found after xref table".to_string()))?;
let dict_start = trailer_pos + 7;
if dict_start >= buffer.len() {
return Err(Error::UnexpectedEof);
}
let (_, trailer_dict) = parse_object(&buffer[dict_start..]).map_err(|e| Error::ParseError {
offset: dict_start,
reason: format!("Failed to parse trailer dictionary: {:?}", e),
})?;
if trailer_dict.as_dict().is_none() {
return Err(Error::InvalidPdf("Trailer is not a dictionary".to_string()));
}
Ok(trailer_dict)
}
fn find_substring(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() {
return Some(0);
}
haystack.windows(needle.len()).position(|window| window == needle)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod ink_dict_extractor_tests;
#[cfg(test)]
mod open_signals_tests;
mod annotations;
mod catalog;
mod columns;
mod extract_api;
mod fonts;
mod images;
mod objects;
mod open;
mod pages;
mod paths;
mod reading_order;
mod rect_api;
mod redaction;
mod span_postprocess;
mod spans_text;
mod tables;
mod text_assembly;