mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 19:48:14 -06:00
usable() and is_catalog() checked the object number only, so a later stream with /Root 1 1 R outranked the trailer naming 1 0 R; get_offset then found no such object and Document Properties lost the catalog. cos resolves by number alone, so its rank needs no change. Test: inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog
2080 lines
80 KiB
Rust
2080 lines
80 KiB
Rust
use log::{error, warn};
|
|
use std::cmp;
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::convert::TryInto;
|
|
#[cfg(not(feature = "async"))]
|
|
use std::fs::File;
|
|
#[cfg(not(feature = "async"))]
|
|
use std::io::Read;
|
|
use std::path::Path;
|
|
use std::sync::Mutex;
|
|
|
|
#[cfg(feature = "rayon")]
|
|
use rayon::prelude::*;
|
|
#[cfg(feature = "async")]
|
|
use tokio::fs::File;
|
|
#[cfg(feature = "async")]
|
|
use tokio::io::{AsyncRead, AsyncReadExt};
|
|
#[cfg(feature = "async")]
|
|
use tokio::pin;
|
|
|
|
use crate::common_data_structures;
|
|
use crate::encryption::{self, EncryptionState};
|
|
use crate::error::{ParseError, XrefError};
|
|
use crate::load_options::{FilterFunc, LoadOptions};
|
|
use crate::object_stream::ObjectStream;
|
|
use crate::parser;
|
|
use crate::xref::{Xref, XrefEntry, XrefType};
|
|
use crate::{Dictionary, Document, Error, IncrementalDocument, Object, ObjectId, Result};
|
|
|
|
#[cfg(not(feature = "async"))]
|
|
impl Document {
|
|
/// Load a PDF document from a specified file path.
|
|
#[inline]
|
|
pub fn load<P: AsRef<Path>>(path: P) -> Result<Document> {
|
|
Self::load_with_options(path, LoadOptions::default())
|
|
}
|
|
|
|
/// Load a PDF document from a specified file path with the given options.
|
|
#[inline]
|
|
pub fn load_with_options<P: AsRef<Path>>(path: P, options: LoadOptions) -> Result<Document> {
|
|
let file = File::open(path)?;
|
|
let capacity = Some(file.metadata()?.len() as usize);
|
|
Self::load_internal(file, capacity, options)
|
|
}
|
|
|
|
/// Load a PDF document from a specified file path with a password for encrypted PDFs.
|
|
#[inline]
|
|
pub fn load_with_password<P: AsRef<Path>>(path: P, password: &str) -> Result<Document> {
|
|
Self::load_with_options(path, LoadOptions::with_password(password))
|
|
}
|
|
|
|
#[deprecated(since = "0.41.0", note = "Use load_with_options instead")]
|
|
#[inline]
|
|
pub fn load_filtered<P: AsRef<Path>>(path: P, filter_func: FilterFunc) -> Result<Document> {
|
|
Self::load_with_options(path, LoadOptions::with_filter(filter_func))
|
|
}
|
|
|
|
/// Load a PDF document from an arbitrary source.
|
|
#[inline]
|
|
pub fn load_from<R: Read>(source: R) -> Result<Document> {
|
|
Self::load_from_with_options(source, LoadOptions::default())
|
|
}
|
|
|
|
/// Load a PDF document from an arbitrary source with the given options.
|
|
#[inline]
|
|
pub fn load_from_with_options<R: Read>(source: R, options: LoadOptions) -> Result<Document> {
|
|
Self::load_internal(source, None, options)
|
|
}
|
|
|
|
/// Load a PDF document from an arbitrary source with a password for encrypted PDFs.
|
|
#[deprecated(since = "0.41.0", note = "Use load_from_with_options instead")]
|
|
#[inline]
|
|
pub fn load_from_with_password<R: Read>(source: R, password: &str) -> Result<Document> {
|
|
Self::load_from_with_options(source, LoadOptions::with_password(password))
|
|
}
|
|
|
|
fn load_internal<R: Read>(mut source: R, capacity: Option<usize>, options: LoadOptions) -> Result<Document> {
|
|
let mut buffer = capacity.map(Vec::with_capacity).unwrap_or_default();
|
|
source.read_to_end(&mut buffer)?;
|
|
|
|
Reader {
|
|
buffer: &buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: options.password,
|
|
strict: options.strict,
|
|
max_decompressed_size: options.max_decompressed_size,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read(options.filter)
|
|
}
|
|
|
|
/// Load a PDF document from a memory slice.
|
|
pub fn load_mem(buffer: &[u8]) -> Result<Document> {
|
|
Self::load_mem_with_options(buffer, LoadOptions::default())
|
|
}
|
|
|
|
/// Load a PDF document from a memory slice with the given options.
|
|
pub fn load_mem_with_options(buffer: &[u8], options: LoadOptions) -> Result<Document> {
|
|
Reader {
|
|
buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: options.password,
|
|
strict: options.strict,
|
|
max_decompressed_size: options.max_decompressed_size,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read(options.filter)
|
|
}
|
|
|
|
/// Load a PDF document from a memory slice with a password for encrypted PDFs.
|
|
#[deprecated(since = "0.41.0", note = "Use load_mem_with_options instead")]
|
|
pub fn load_mem_with_password(buffer: &[u8], password: &str) -> Result<Document> {
|
|
Self::load_mem_with_options(buffer, LoadOptions::with_password(password))
|
|
}
|
|
|
|
/// Load PDF metadata (title and page count) without loading the entire document.
|
|
/// This is much faster for large PDFs when you only need basic information.
|
|
#[inline]
|
|
pub fn load_metadata<P: AsRef<Path>>(path: P) -> Result<PdfMetadata> {
|
|
let file = File::open(path)?;
|
|
let capacity = Some(file.metadata()?.len() as usize);
|
|
Self::load_metadata_internal(file, capacity, None)
|
|
}
|
|
|
|
/// Load PDF metadata from a file path with a password for encrypted PDFs.
|
|
#[inline]
|
|
pub fn load_metadata_with_password<P: AsRef<Path>>(path: P, password: &str) -> Result<PdfMetadata> {
|
|
let file = File::open(path)?;
|
|
let capacity = Some(file.metadata()?.len() as usize);
|
|
Self::load_metadata_internal(file, capacity, Some(password.to_string()))
|
|
}
|
|
|
|
/// Load PDF metadata from an arbitrary source without loading the entire document.
|
|
#[inline]
|
|
pub fn load_metadata_from<R: Read>(source: R) -> Result<PdfMetadata> {
|
|
Self::load_metadata_internal(source, None, None)
|
|
}
|
|
|
|
/// Load PDF metadata from an arbitrary source with a password for encrypted PDFs.
|
|
#[inline]
|
|
pub fn load_metadata_from_with_password<R: Read>(source: R, password: &str) -> Result<PdfMetadata> {
|
|
Self::load_metadata_internal(source, None, Some(password.to_string()))
|
|
}
|
|
|
|
/// Load PDF metadata from a memory slice without loading the entire document.
|
|
#[inline]
|
|
pub fn load_metadata_mem(buffer: &[u8]) -> Result<PdfMetadata> {
|
|
Reader {
|
|
buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: None,
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read_metadata()
|
|
}
|
|
|
|
/// Load PDF metadata from a memory slice with a password for encrypted PDFs.
|
|
#[inline]
|
|
pub fn load_metadata_mem_with_password(buffer: &[u8], password: &str) -> Result<PdfMetadata> {
|
|
Reader {
|
|
buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: Some(password.to_string()),
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read_metadata()
|
|
}
|
|
|
|
fn load_metadata_internal<R: Read>(
|
|
mut source: R, capacity: Option<usize>, password: Option<String>,
|
|
) -> Result<PdfMetadata> {
|
|
let mut buffer = capacity.map(Vec::with_capacity).unwrap_or_default();
|
|
source.read_to_end(&mut buffer)?;
|
|
|
|
Reader {
|
|
buffer: &buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password,
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read_metadata()
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "async")]
|
|
impl Document {
|
|
pub async fn load<P: AsRef<Path>>(path: P) -> Result<Document> {
|
|
Self::load_with_options(path, LoadOptions::default()).await
|
|
}
|
|
|
|
/// Load a PDF document from a specified file path with the given options.
|
|
pub async fn load_with_options<P: AsRef<Path>>(path: P, options: LoadOptions) -> Result<Document> {
|
|
let file = File::open(path).await?;
|
|
let metadata = file.metadata().await?;
|
|
let capacity = Some(metadata.len() as usize);
|
|
Self::load_internal(file, capacity, options).await
|
|
}
|
|
|
|
/// Load a PDF document from a specified file path with a password for encrypted PDFs.
|
|
pub async fn load_with_password<P: AsRef<Path>>(path: P, password: &str) -> Result<Document> {
|
|
Self::load_with_options(path, LoadOptions::with_password(password)).await
|
|
}
|
|
|
|
#[deprecated(since = "0.41.0", note = "Use load_with_options instead")]
|
|
pub async fn load_filtered<P: AsRef<Path>>(path: P, filter_func: FilterFunc) -> Result<Document> {
|
|
Self::load_with_options(path, LoadOptions::with_filter(filter_func)).await
|
|
}
|
|
|
|
async fn load_internal<R: AsyncRead>(source: R, capacity: Option<usize>, options: LoadOptions) -> Result<Document> {
|
|
pin!(source);
|
|
|
|
let mut buffer = capacity.map(Vec::with_capacity).unwrap_or_default();
|
|
source.read_to_end(&mut buffer).await?;
|
|
|
|
Reader {
|
|
buffer: &buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: options.password,
|
|
strict: options.strict,
|
|
max_decompressed_size: options.max_decompressed_size,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read(options.filter)
|
|
}
|
|
|
|
/// Load a PDF document from a memory slice.
|
|
pub fn load_mem(buffer: &[u8]) -> Result<Document> {
|
|
Self::load_mem_with_options(buffer, LoadOptions::default())
|
|
}
|
|
|
|
/// Load a PDF document from a memory slice with the given options.
|
|
pub fn load_mem_with_options(buffer: &[u8], options: LoadOptions) -> Result<Document> {
|
|
Reader {
|
|
buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: options.password,
|
|
strict: options.strict,
|
|
max_decompressed_size: options.max_decompressed_size,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read(options.filter)
|
|
}
|
|
|
|
/// Load PDF metadata (title and page count) without loading the entire document.
|
|
/// This is much faster for large PDFs when you only need basic information.
|
|
#[inline]
|
|
pub async fn load_metadata<P: AsRef<Path>>(path: P) -> Result<PdfMetadata> {
|
|
let file = File::open(path).await?;
|
|
let metadata = file.metadata().await?;
|
|
let capacity = Some(metadata.len() as usize);
|
|
Self::load_metadata_internal(file, capacity, None).await
|
|
}
|
|
|
|
/// Load PDF metadata from a file path with a password for encrypted PDFs.
|
|
#[inline]
|
|
pub async fn load_metadata_with_password<P: AsRef<Path>>(path: P, password: &str) -> Result<PdfMetadata> {
|
|
let file = File::open(path).await?;
|
|
let metadata = file.metadata().await?;
|
|
let capacity = Some(metadata.len() as usize);
|
|
Self::load_metadata_internal(file, capacity, Some(password.to_string())).await
|
|
}
|
|
|
|
/// Load PDF metadata from an arbitrary source without loading the entire document.
|
|
#[inline]
|
|
pub async fn load_metadata_from<R: AsyncRead>(source: R) -> Result<PdfMetadata> {
|
|
Self::load_metadata_internal(source, None, None).await
|
|
}
|
|
|
|
/// Load PDF metadata from an arbitrary source with a password for encrypted PDFs.
|
|
#[inline]
|
|
pub async fn load_metadata_from_with_password<R: AsyncRead>(source: R, password: &str) -> Result<PdfMetadata> {
|
|
Self::load_metadata_internal(source, None, Some(password.to_string())).await
|
|
}
|
|
|
|
/// Load PDF metadata from a memory slice without loading the entire document.
|
|
#[inline]
|
|
pub fn load_metadata_mem(buffer: &[u8]) -> Result<PdfMetadata> {
|
|
Reader {
|
|
buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: None,
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read_metadata()
|
|
}
|
|
|
|
/// Load PDF metadata from a memory slice with a password for encrypted PDFs.
|
|
#[inline]
|
|
pub fn load_metadata_mem_with_password(buffer: &[u8], password: &str) -> Result<PdfMetadata> {
|
|
Reader {
|
|
buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: Some(password.to_string()),
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read_metadata()
|
|
}
|
|
|
|
async fn load_metadata_internal<R: AsyncRead>(
|
|
source: R, capacity: Option<usize>, password: Option<String>,
|
|
) -> Result<PdfMetadata> {
|
|
pin!(source);
|
|
|
|
let mut buffer = capacity.map(Vec::with_capacity).unwrap_or_default();
|
|
source.read_to_end(&mut buffer).await?;
|
|
|
|
Reader {
|
|
buffer: &buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password,
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read_metadata()
|
|
}
|
|
}
|
|
|
|
impl TryInto<Document> for &[u8] {
|
|
type Error = Error;
|
|
|
|
fn try_into(self) -> Result<Document> {
|
|
Reader {
|
|
buffer: self,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: None,
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read(None)
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "async"))]
|
|
impl IncrementalDocument {
|
|
/// Load a PDF document from a specified file path.
|
|
#[inline]
|
|
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
|
|
let file = File::open(path)?;
|
|
let capacity = Some(file.metadata()?.len() as usize);
|
|
Self::load_internal(file, capacity)
|
|
}
|
|
|
|
/// Load a PDF document from an arbitrary source.
|
|
#[inline]
|
|
pub fn load_from<R: Read>(source: R) -> Result<Self> {
|
|
Self::load_internal(source, None)
|
|
}
|
|
|
|
fn load_internal<R: Read>(mut source: R, capacity: Option<usize>) -> Result<Self> {
|
|
let mut buffer = capacity.map(Vec::with_capacity).unwrap_or_default();
|
|
source.read_to_end(&mut buffer)?;
|
|
|
|
let document = Reader {
|
|
buffer: &buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: None,
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read(None)?;
|
|
|
|
Ok(IncrementalDocument::create_from(buffer, document))
|
|
}
|
|
|
|
/// Load a PDF document from a memory slice.
|
|
pub fn load_mem(buffer: &[u8]) -> Result<Document> {
|
|
buffer.try_into()
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "async")]
|
|
impl IncrementalDocument {
|
|
/// Load a PDF document from a specified file path.
|
|
#[inline]
|
|
pub async fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
|
|
let file = File::open(path).await?;
|
|
let metadata = file.metadata().await?;
|
|
let capacity = Some(metadata.len() as usize);
|
|
Self::load_internal(file, capacity).await
|
|
}
|
|
|
|
/// Load a PDF document from an arbitrary source.
|
|
#[inline]
|
|
pub async fn load_from<R: AsyncRead>(source: R) -> Result<Self> {
|
|
Self::load_internal(source, None).await
|
|
}
|
|
|
|
async fn load_internal<R: AsyncRead>(source: R, capacity: Option<usize>) -> Result<Self> {
|
|
pin!(source);
|
|
|
|
let mut buffer = capacity.map(Vec::with_capacity).unwrap_or_default();
|
|
source.read_to_end(&mut buffer).await?;
|
|
|
|
let document = Reader {
|
|
buffer: &buffer,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: None,
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read(None)?;
|
|
|
|
Ok(IncrementalDocument::create_from(buffer, document))
|
|
}
|
|
|
|
/// Load a PDF document from a memory slice.
|
|
pub fn load_mem(buffer: &[u8]) -> Result<Document> {
|
|
buffer.try_into()
|
|
}
|
|
}
|
|
|
|
impl TryInto<IncrementalDocument> for &[u8] {
|
|
type Error = Error;
|
|
|
|
fn try_into(self) -> Result<IncrementalDocument> {
|
|
let document = Reader {
|
|
buffer: self,
|
|
document: Document::new(),
|
|
encryption_state: None,
|
|
raw_objects: BTreeMap::new(),
|
|
password: None,
|
|
strict: false,
|
|
max_decompressed_size: None,
|
|
normal_offsets: Vec::new(),
|
|
}
|
|
.read(None)?;
|
|
|
|
Ok(IncrementalDocument::create_from(self.to_vec(), document))
|
|
}
|
|
}
|
|
|
|
pub struct Reader<'a> {
|
|
pub buffer: &'a [u8],
|
|
pub document: Document,
|
|
pub encryption_state: Option<EncryptionState>,
|
|
pub raw_objects: BTreeMap<ObjectId, Vec<u8>>, // Store raw bytes for encrypted objects
|
|
pub password: Option<String>, // Password for encrypted PDFs
|
|
pub strict: bool, // Reject non-conforming PDFs when true
|
|
/// Maximum decompressed size, in bytes, allowed for any single stream
|
|
/// (object streams and cross-reference streams) decoded during loading.
|
|
/// `None` (the default) applies no limit; set it to guard against
|
|
/// decompression bombs. See [`crate::LoadOptions`].
|
|
pub max_decompressed_size: Option<usize>,
|
|
/// Sorted unique byte offsets of every `XrefEntry::Normal` entry in the
|
|
/// final cross-reference table. Built once when the table is complete so
|
|
/// object-boundary lookups can binary-search the successor offset instead
|
|
/// of scanning all xref entries on each call.
|
|
normal_offsets: Vec<usize>,
|
|
}
|
|
|
|
/// Maximum allowed embedding of literal strings.
|
|
pub const MAX_BRACKET: usize = 100;
|
|
|
|
pub const MAX_NESTING_DEPTH: usize = 100;
|
|
|
|
/// PdfCraft patch: a valid stream /Length resolves in at most three lookups (the length, the object stream holding
|
|
/// it, and that stream's own /Length); longer lookup chains overflowed the stack.
|
|
const MAX_LOOKUP_DEPTH: usize = 16;
|
|
|
|
/// Cap on reconstructed cross-reference entries, bounding memory on hostile inputs.
|
|
const MAX_RECONSTRUCTED_OBJECTS: usize = 1_000_000;
|
|
|
|
/// Cap on how many trailing `trailer` dictionaries are inspected for a `/Root`.
|
|
const MAX_TRAILER_CANDIDATES: usize = 16;
|
|
|
|
/// PDF metadata extracted without loading the entire document.
|
|
/// This is useful for quickly getting basic information about large PDFs.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PdfMetadata {
|
|
/// Document title from Info dictionary
|
|
pub title: Option<String>,
|
|
/// Document author from Info dictionary
|
|
pub author: Option<String>,
|
|
/// Document subject from Info dictionary
|
|
pub subject: Option<String>,
|
|
/// Document keywords from Info dictionary
|
|
pub keywords: Option<String>,
|
|
/// Application that created the document
|
|
pub creator: Option<String>,
|
|
/// Application that produced the document
|
|
pub producer: Option<String>,
|
|
/// Document creation date (PDF date format: D:YYYYMMDDHHmmSSOHH'mm')
|
|
pub creation_date: Option<String>,
|
|
/// Document modification date (PDF date format: D:YYYYMMDDHHmmSSOHH'mm')
|
|
pub modification_date: Option<String>,
|
|
/// Custom Info dictionary entries that are not part of the standard set above.
|
|
///
|
|
/// Producers commonly use the Info dictionary to attach application-specific
|
|
/// metadata (for example, Microsoft Information Protection labels stored as
|
|
/// `MSIP_Label_{GUID}_{Property}` keys). Such entries used to be discarded
|
|
/// by the metadata-only loader; they are now preserved here as raw `Object`
|
|
/// values so callers can inspect or decode them as needed.
|
|
pub custom: HashMap<Vec<u8>, Object>,
|
|
/// Number of pages in the document
|
|
pub page_count: u32,
|
|
/// PDF version
|
|
pub version: String,
|
|
/// Whether the document declares encryption (an `/Encrypt` entry in the
|
|
/// trailer). When `true`, string and stream contents are encrypted; the
|
|
/// standard Info fields above are populated only if the document could be
|
|
/// decrypted — e.g. an empty user password, or a password supplied via a
|
|
/// `*_with_password` loader.
|
|
pub encrypted: bool,
|
|
}
|
|
|
|
struct InfoMetadata {
|
|
title: Option<String>,
|
|
author: Option<String>,
|
|
subject: Option<String>,
|
|
keywords: Option<String>,
|
|
creator: Option<String>,
|
|
producer: Option<String>,
|
|
creation_date: Option<String>,
|
|
modification_date: Option<String>,
|
|
custom: HashMap<Vec<u8>, Object>,
|
|
}
|
|
|
|
impl InfoMetadata {
|
|
fn empty() -> Self {
|
|
Self {
|
|
title: None,
|
|
author: None,
|
|
subject: None,
|
|
keywords: None,
|
|
creator: None,
|
|
producer: None,
|
|
creation_date: None,
|
|
modification_date: None,
|
|
custom: HashMap::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Standard Info dictionary keys defined in PDF 1.7 §14.3.3 ("Document
|
|
/// Information Dictionary"). Any other key is preserved on
|
|
/// `PdfMetadata::custom` rather than dropped.
|
|
const STANDARD_INFO_KEYS: &[&[u8]] = &[
|
|
b"Title",
|
|
b"Author",
|
|
b"Subject",
|
|
b"Keywords",
|
|
b"Creator",
|
|
b"Producer",
|
|
b"CreationDate",
|
|
b"ModDate",
|
|
b"Trapped",
|
|
];
|
|
|
|
impl Reader<'_> {
|
|
/// Read metadata (title and page count) without loading the entire document.
|
|
/// This is much faster for large PDFs when you only need basic information.
|
|
///
|
|
/// For encrypted PDFs, use `Document::load_metadata_with_password()` instead.
|
|
pub fn read_metadata(mut self) -> Result<PdfMetadata> {
|
|
let offset = self.buffer.windows(5).position(|w| w == b"%PDF-").unwrap_or(0);
|
|
self.buffer = &self.buffer[offset..];
|
|
|
|
let version = parser::header(self.buffer, self.strict).ok_or(ParseError::InvalidFileHeader)?;
|
|
|
|
let (xref, trailer) = match self.resolve_xref_and_trailer() {
|
|
Ok(resolved) => resolved,
|
|
Err(err) => match self.reconstruct_xref_and_trailer() {
|
|
Some(reconstructed) => {
|
|
warn!("cross-reference resolution failed ({err}); recovered by scanning for indirect objects");
|
|
reconstructed
|
|
}
|
|
None => return Err(err),
|
|
},
|
|
};
|
|
|
|
self.set_reference_table(xref);
|
|
self.document.trailer = trailer.clone();
|
|
let encrypted = self.document.trailer.get(b"Encrypt").is_ok();
|
|
// For encrypted PDFs, set up decryption so the Info dictionary can be
|
|
// read. If the document is protected by a password we cannot supply,
|
|
// still report what we have (notably `encrypted`) rather than failing
|
|
// the whole call; callers needing the Info fields should use a
|
|
// `*_with_password` loader.
|
|
let needs_password = if encrypted {
|
|
match self.setup_encryption_for_metadata() {
|
|
Ok(()) => false,
|
|
// No usable password was available (empty password failed and
|
|
// none was supplied): report `encrypted` without the Info
|
|
// fields. A wrong supplied password still surfaces as an error.
|
|
Err(Error::Unimplemented(_)) => true,
|
|
Err(e) => return Err(e),
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
|
|
let info_metadata = if needs_password {
|
|
InfoMetadata::empty()
|
|
} else {
|
|
self.extract_info_metadata()?
|
|
};
|
|
let page_count = if needs_password { 0 } else { self.extract_page_count()? };
|
|
|
|
Ok(PdfMetadata {
|
|
title: info_metadata.title,
|
|
author: info_metadata.author,
|
|
subject: info_metadata.subject,
|
|
keywords: info_metadata.keywords,
|
|
creator: info_metadata.creator,
|
|
producer: info_metadata.producer,
|
|
creation_date: info_metadata.creation_date,
|
|
modification_date: info_metadata.modification_date,
|
|
custom: info_metadata.custom,
|
|
page_count,
|
|
version,
|
|
encrypted,
|
|
})
|
|
}
|
|
|
|
fn extract_info_metadata(&self) -> Result<InfoMetadata> {
|
|
let info_ref = match self.document.trailer.get(b"Info") {
|
|
Ok(obj) => obj.as_reference().ok(),
|
|
Err(_) => return Ok(InfoMetadata::empty()),
|
|
};
|
|
|
|
let info_id = match info_ref {
|
|
Some(id) => id,
|
|
None => return Ok(InfoMetadata::empty()),
|
|
};
|
|
|
|
let mut already_seen = HashSet::new();
|
|
let info_obj = match self.get_object(info_id, &mut already_seen) {
|
|
Ok(obj) => obj,
|
|
Err(_) => return Ok(InfoMetadata::empty()),
|
|
};
|
|
|
|
let info_dict = match info_obj.as_dict() {
|
|
Ok(dict) => dict,
|
|
Err(_) => return Ok(InfoMetadata::empty()),
|
|
};
|
|
|
|
let mut custom = HashMap::new();
|
|
for (key, value) in info_dict.iter() {
|
|
if STANDARD_INFO_KEYS.contains(&key.as_slice()) {
|
|
continue;
|
|
}
|
|
custom.insert(key.clone(), value.clone());
|
|
}
|
|
|
|
Ok(InfoMetadata {
|
|
title: Self::extract_string_field(info_dict, b"Title"),
|
|
author: Self::extract_string_field(info_dict, b"Author"),
|
|
subject: Self::extract_string_field(info_dict, b"Subject"),
|
|
keywords: Self::extract_string_field(info_dict, b"Keywords"),
|
|
creator: Self::extract_string_field(info_dict, b"Creator"),
|
|
producer: Self::extract_string_field(info_dict, b"Producer"),
|
|
creation_date: Self::extract_string_field(info_dict, b"CreationDate"),
|
|
modification_date: Self::extract_string_field(info_dict, b"ModDate"),
|
|
custom,
|
|
})
|
|
}
|
|
|
|
fn extract_string_field(dict: &Dictionary, key: &[u8]) -> Option<String> {
|
|
match dict.get(key) {
|
|
Ok(obj) => match obj {
|
|
Object::String(_bytes, _) => common_data_structures::decode_text_string(obj).ok(),
|
|
_ => None,
|
|
},
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
|
|
fn extract_page_count(&self) -> Result<u32> {
|
|
let root_ref = match self.document.trailer.get(b"Root").and_then(Object::as_reference) {
|
|
Ok(id) => id,
|
|
Err(_) => return Ok(0),
|
|
};
|
|
|
|
let mut already_seen = HashSet::new();
|
|
let catalog_obj = match self.get_object(root_ref, &mut already_seen) {
|
|
Ok(obj) => obj,
|
|
Err(_) => return Ok(0),
|
|
};
|
|
|
|
let catalog_dict = match catalog_obj.as_dict() {
|
|
Ok(dict) => dict,
|
|
Err(_) => return Ok(0),
|
|
};
|
|
|
|
let pages_ref = match catalog_dict.get(b"Pages").and_then(Object::as_reference) {
|
|
Ok(id) => id,
|
|
Err(_) => return Ok(0),
|
|
};
|
|
|
|
self.get_pages_tree_count(pages_ref, &mut HashSet::new(), 0).or(Ok(0))
|
|
}
|
|
|
|
fn get_pages_tree_count(&self, pages_id: ObjectId, seen: &mut HashSet<ObjectId>, depth: usize) -> Result<u32> {
|
|
// `seen` catches a repeated node (a cycle); it does not bound the depth of a long,
|
|
// non-cyclic /Pages chain with no /Count, which recurses once per level. Cap that
|
|
// separately, matching this crate's existing MAX_NESTING_DEPTH budget.
|
|
if depth >= MAX_NESTING_DEPTH {
|
|
return Ok(0);
|
|
}
|
|
if seen.contains(&pages_id) {
|
|
return Err(Error::ReferenceCycle(pages_id));
|
|
}
|
|
seen.insert(pages_id);
|
|
|
|
let mut already_seen = HashSet::new();
|
|
let pages_obj = match self.get_object(pages_id, &mut already_seen) {
|
|
Ok(obj) => obj,
|
|
Err(_) => return Ok(0),
|
|
};
|
|
|
|
let pages_dict = match pages_obj.as_dict() {
|
|
Ok(dict) => dict,
|
|
Err(_) => return Ok(0),
|
|
};
|
|
|
|
match pages_dict.get_type() {
|
|
Ok(type_name) if type_name == b"Page" => Ok(1),
|
|
Ok(type_name) if type_name == b"Pages" => {
|
|
if let Ok(count_obj) = pages_dict.get(b"Count")
|
|
&& let Ok(count) = count_obj.as_i64()
|
|
&& count >= 0
|
|
{
|
|
return Ok(count as u32);
|
|
}
|
|
|
|
let kids = match pages_dict.get(b"Kids").and_then(Object::as_array) {
|
|
Ok(arr) => arr,
|
|
Err(_) => return Ok(0),
|
|
};
|
|
|
|
let mut total = 0u32;
|
|
for kid in kids.iter() {
|
|
if let Ok(kid_ref) = kid.as_reference()
|
|
&& let Ok(count) = self.get_pages_tree_count(kid_ref, seen, depth + 1)
|
|
{
|
|
total += count;
|
|
}
|
|
}
|
|
Ok(total)
|
|
}
|
|
_ => Ok(1),
|
|
}
|
|
}
|
|
|
|
/// Read whole document.
|
|
pub fn read(mut self, filter_func: Option<FilterFunc>) -> Result<Document> {
|
|
let offset = self.buffer.windows(5).position(|w| w == b"%PDF-").unwrap_or(0);
|
|
self.buffer = &self.buffer[offset..];
|
|
|
|
// The document structure can be expressed in PEG as:
|
|
// document <- header indirect_object* xref trailer xref_start
|
|
let version = parser::header(self.buffer, self.strict).ok_or(ParseError::InvalidFileHeader)?;
|
|
|
|
//The binary_mark is in line 2 after the pdf version. If at other line number, then will be declared as invalid
|
|
// pdf.
|
|
if let Some(pos) = self.buffer.iter().position(|&byte| byte == b'\n')
|
|
&& let Some(binary_mark) = parser::binary_mark(&self.buffer[pos + 1..])
|
|
&& binary_mark.iter().all(|&byte| byte >= 128)
|
|
{
|
|
self.document.binary_mark = binary_mark;
|
|
}
|
|
|
|
// Fall back to reconstruction only after all standard resolution fails.
|
|
let (xref, trailer) = match self.resolve_xref_and_trailer() {
|
|
Ok(resolved) => resolved,
|
|
Err(err) => match self.reconstruct_xref_and_trailer() {
|
|
Some(reconstructed) => {
|
|
warn!("cross-reference resolution failed ({err}); recovered by scanning for indirect objects");
|
|
reconstructed
|
|
}
|
|
None => return Err(err),
|
|
},
|
|
};
|
|
|
|
self.document.version = version;
|
|
self.document.max_id = xref.size - 1;
|
|
self.document.trailer = trailer;
|
|
self.set_reference_table(xref);
|
|
|
|
// Check if encrypted
|
|
let is_encrypted = self.document.trailer.get(b"Encrypt").is_ok();
|
|
|
|
if is_encrypted {
|
|
// For encrypted PDFs, use a special loading strategy
|
|
self.load_encrypted_document(filter_func)?;
|
|
} else {
|
|
// For non-encrypted PDFs, use the normal loading
|
|
self.load_objects_raw(filter_func)?;
|
|
}
|
|
|
|
// Object-stream members join `objects` only during loading, after
|
|
// `max_id` was derived from the xref size. Keep the ceiling at least
|
|
// at the highest loaded object so new ids cannot collide with live ones.
|
|
if let Some(&max_loaded_id) = self.document.objects.keys().next_back() {
|
|
self.document.max_id = self.document.max_id.max(max_loaded_id.0);
|
|
}
|
|
|
|
Ok(self.document)
|
|
}
|
|
|
|
fn load_encrypted_document(&mut self, _filter_func: Option<FilterFunc>) -> Result<()> {
|
|
// First, extract all raw object bytes without parsing
|
|
let entries: Vec<_> = self
|
|
.document
|
|
.reference_table
|
|
.entries
|
|
.iter()
|
|
.map(|(k, v)| (*k, v.clone()))
|
|
.collect();
|
|
|
|
let mut object_streams = Vec::new();
|
|
|
|
for (obj_num, entry) in entries {
|
|
match entry {
|
|
XrefEntry::Normal { offset, .. } => {
|
|
if let Ok((obj_id, raw_bytes)) = self.extract_raw_object(offset as usize) {
|
|
self.raw_objects.insert(obj_id, raw_bytes);
|
|
}
|
|
}
|
|
XrefEntry::Compressed { container, index } => {
|
|
// Store compressed object info for later processing
|
|
object_streams.push((obj_num, container, index));
|
|
}
|
|
XrefEntry::Free | XrefEntry::UnusableFree => {
|
|
// Skip free entries
|
|
}
|
|
}
|
|
}
|
|
|
|
self.parse_encryption_dictionary()?;
|
|
|
|
if self.authenticate_and_setup_encryption(false)?.is_none() {
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(ref state) = self.encryption_state {
|
|
let encrypt_ref = self
|
|
.document
|
|
.trailer
|
|
.get(b"Encrypt")
|
|
.ok()
|
|
.and_then(|o| o.as_reference().ok());
|
|
|
|
for (obj_id, raw_bytes) in &self.raw_objects {
|
|
if let Some(enc_ref) = encrypt_ref
|
|
&& *obj_id == enc_ref
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if let Ok((id, mut obj)) = self.parse_raw_object(raw_bytes) {
|
|
let _ = encryption::decrypt_object(state, *obj_id, &mut obj);
|
|
self.document.objects.insert(id, obj);
|
|
}
|
|
}
|
|
|
|
let mut streams_to_process: std::collections::HashMap<u32, Vec<(u32, u16)>> =
|
|
std::collections::HashMap::new();
|
|
for (obj_num, container_id, index) in object_streams {
|
|
streams_to_process
|
|
.entry(container_id)
|
|
.or_default()
|
|
.push((obj_num, index));
|
|
}
|
|
|
|
for (container_id, objects_in_stream) in streams_to_process {
|
|
if let Some(container_obj) = self.document.objects.get(&(container_id, 0))
|
|
&& let Ok(stream) = container_obj.as_stream()
|
|
{
|
|
match ObjectStream::new_with_limit(stream, self.max_decompressed_size) {
|
|
Ok(object_stream) => {
|
|
for (obj_num, _index) in objects_in_stream {
|
|
let obj_id = (obj_num, 0);
|
|
if let Some(obj) = object_stream.objects.get(&obj_id) {
|
|
self.document.objects.insert(obj_id, obj.clone());
|
|
}
|
|
}
|
|
}
|
|
Err(_e) => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut stored_state = state.clone();
|
|
// Record the /Encrypt dictionary's object id so that a later
|
|
// incremental save can restore `/Encrypt N G R` in the appended
|
|
// trailer. See `IncrementalDocument::save_internal`.
|
|
stored_state.encrypt_object_id = encrypt_ref;
|
|
self.document.encryption_state = Some(stored_state);
|
|
|
|
if let Some(enc_ref) = encrypt_ref {
|
|
self.document.objects.remove(&enc_ref);
|
|
}
|
|
self.document.trailer.remove(b"Encrypt");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_raw_object(&self, raw_bytes: &[u8]) -> Result<(ObjectId, Object)> {
|
|
// Parse the raw bytes as an indirect object
|
|
parser::indirect_object(raw_bytes, 0, None, self, &mut HashSet::new(), None)
|
|
}
|
|
|
|
/// Install a fully merged cross-reference table and derive its sorted
|
|
/// offset index. All later object-boundary lookups go through that index,
|
|
/// so it must be rebuilt whenever the table is replaced.
|
|
fn set_reference_table(&mut self, xref: Xref) {
|
|
let mut normal_offsets: Vec<usize> = xref
|
|
.entries
|
|
.values()
|
|
.filter_map(|entry| match entry {
|
|
XrefEntry::Normal { offset, .. } => Some(*offset as usize),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
normal_offsets.sort_unstable();
|
|
normal_offsets.dedup();
|
|
self.normal_offsets = normal_offsets;
|
|
self.document.reference_table = xref;
|
|
}
|
|
|
|
fn load_objects_raw(&mut self, filter_func: Option<FilterFunc>) -> Result<()> {
|
|
let is_encrypted = self.document.trailer.get(b"Encrypt").is_ok();
|
|
let zero_length_streams = Mutex::new(vec![]);
|
|
let object_streams = Mutex::new(vec![]);
|
|
|
|
// Build a map of which container each compressed object belongs to
|
|
// according to the xref. This prevents stale ObjStm copies (e.g., from
|
|
// linearization first-page sections) from overriding the correct version.
|
|
let compressed_obj_containers: BTreeMap<u32, u32> = self
|
|
.document
|
|
.reference_table
|
|
.entries
|
|
.iter()
|
|
.filter_map(|(&id, entry)| {
|
|
if let XrefEntry::Compressed { container, .. } = entry {
|
|
Some((id, *container))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
let normal_offsets = &self.normal_offsets;
|
|
|
|
let entries_filter_map = |(_, entry): (&_, &_)| -> Result<Option<(ObjectId, Object)>> {
|
|
if let XrefEntry::Normal { offset, .. } = *entry {
|
|
// read_object now handles decryption internally
|
|
let offset = offset as usize;
|
|
let next_index = normal_offsets.partition_point(|&next| next <= offset);
|
|
let next_object = normal_offsets.get(next_index).copied();
|
|
let end = self.object_end(offset, next_object);
|
|
let result = self.read_object_to(offset, end, None, &mut HashSet::new());
|
|
let (object_id, mut object) = match result {
|
|
Ok(obj) => obj,
|
|
Err(e) => {
|
|
if is_encrypted {
|
|
// Expected for some encrypted objects - but log which
|
|
// ones. These failures stay non-fatal even in strict
|
|
// mode because they do not indicate a malformed file.
|
|
warn!("Skipping encrypted object at offset {}: {:?}", offset, e);
|
|
return Ok(None);
|
|
}
|
|
// Lenient loading logs and skips malformed objects; strict loading fails.
|
|
error!("Object load error at offset {}: {e:?}", offset);
|
|
return if self.strict { Err(e) } else { Ok(None) };
|
|
}
|
|
};
|
|
if let Some(filter_func) = filter_func
|
|
&& filter_func(object_id, &mut object).is_none()
|
|
{
|
|
return Ok(None);
|
|
}
|
|
|
|
if let Ok(stream) = object.as_stream() {
|
|
if stream.dict.has_type(b"ObjStm") && !is_encrypted {
|
|
let obj_stream = match ObjectStream::new_with_limit(stream, self.max_decompressed_size) {
|
|
Ok(obj_stream) => obj_stream,
|
|
// Not an encryption-related loss, so it obeys the same
|
|
// strict-mode policy as any other load error.
|
|
Err(e) => {
|
|
error!("Object stream load error for {object_id:?}: {e:?}");
|
|
return if self.strict { Err(e) } else { Ok(None) };
|
|
}
|
|
};
|
|
let container_id = object_id.0;
|
|
let mut object_streams = object_streams.lock().unwrap();
|
|
if let Some(filter_func) = filter_func {
|
|
let objects: BTreeMap<(u32, u16), Object> = obj_stream
|
|
.objects
|
|
.into_iter()
|
|
.filter(|((obj_num, _), _)| {
|
|
compressed_obj_containers
|
|
.get(obj_num)
|
|
.is_none_or(|&c| c == container_id)
|
|
})
|
|
.filter_map(|(object_id, mut object)| filter_func(object_id, &mut object))
|
|
.collect();
|
|
object_streams.extend(objects);
|
|
} else {
|
|
object_streams.extend(obj_stream.objects.into_iter().filter(|((obj_num, _), _)| {
|
|
compressed_obj_containers
|
|
.get(obj_num)
|
|
.is_none_or(|&c| c == container_id)
|
|
}));
|
|
}
|
|
} else if stream.content.is_empty() {
|
|
let mut zero_length_streams = zero_length_streams.lock().unwrap();
|
|
zero_length_streams.push(object_id);
|
|
}
|
|
}
|
|
|
|
Ok(Some((object_id, object)))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
};
|
|
|
|
#[cfg(feature = "rayon")]
|
|
{
|
|
self.document.objects = self
|
|
.document
|
|
.reference_table
|
|
.entries
|
|
.par_iter()
|
|
.map(entries_filter_map)
|
|
.filter_map(Result::transpose)
|
|
.collect::<Result<BTreeMap<_, _>>>()?;
|
|
}
|
|
#[cfg(not(feature = "rayon"))]
|
|
{
|
|
self.document.objects = self
|
|
.document
|
|
.reference_table
|
|
.entries
|
|
.iter()
|
|
.map(entries_filter_map)
|
|
.filter_map(Result::transpose)
|
|
.collect::<Result<BTreeMap<_, _>>>()?;
|
|
}
|
|
|
|
// Only add entries, but never replace entries
|
|
for (id, entry) in object_streams.into_inner().unwrap() {
|
|
self.document.objects.entry(id).or_insert(entry);
|
|
}
|
|
|
|
for object_id in zero_length_streams.into_inner().unwrap() {
|
|
let _ = self.read_stream_content(object_id);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn read_stream_content(&mut self, object_id: ObjectId) -> Result<()> {
|
|
let length = self.get_stream_length(object_id)?;
|
|
let stream = self
|
|
.document
|
|
.get_object_mut(object_id)
|
|
.and_then(Object::as_stream_mut)?;
|
|
let start = stream
|
|
.start_position
|
|
.ok_or(Error::InvalidStream("missing start position".to_string()))?;
|
|
|
|
if length < 0 {
|
|
return Err(Error::InvalidStream("negative stream length.".to_string()));
|
|
}
|
|
|
|
let length = usize::try_from(length).map_err(|e| Error::NumericCast(e.to_string()))?;
|
|
let end = start + length;
|
|
|
|
if end > self.buffer.len() {
|
|
return Err(Error::InvalidStream("stream extends after document end.".to_string()));
|
|
}
|
|
|
|
stream.set_content(self.buffer[start..end].to_vec());
|
|
Ok(())
|
|
}
|
|
|
|
fn get_stream_length(&self, object_id: ObjectId) -> Result<i64> {
|
|
let object = self.document.get_object(object_id)?;
|
|
let stream = object.as_stream()?;
|
|
stream
|
|
.dict
|
|
.get(b"Length")
|
|
.and_then(|value| self.document.dereference(value))
|
|
.and_then(|(_id, obj)| match obj.as_i64() {
|
|
Ok(length) => Ok(length),
|
|
// ISO 32000-1 s7.3.8.2 requires /Length to be an integer, but some producers
|
|
// write it as a real ("42." instead of "42"). Accept one whose value is
|
|
// integral and in range rather than dropping the stream's content.
|
|
Err(err) => match obj.as_f32() {
|
|
Ok(value) if value.fract() == 0.0 && value >= -(2f32.powi(63)) && value < 2f32.powi(63) => {
|
|
Ok(value as i64)
|
|
}
|
|
_ => Err(err),
|
|
},
|
|
})
|
|
.inspect_err(|_err| {
|
|
error!(
|
|
"stream dictionary of '{} {} R' is missing the Length entry",
|
|
object_id.0, object_id.1
|
|
);
|
|
})
|
|
}
|
|
|
|
/// Get object offset by object ID.
|
|
fn get_offset(&self, id: ObjectId) -> Result<u32> {
|
|
let entry = self.document.reference_table.get(id.0).ok_or(Error::MissingXrefEntry)?;
|
|
match *entry {
|
|
XrefEntry::Normal { offset, generation } if generation == id.1 => Ok(offset),
|
|
_ => Err(Error::MissingXrefEntry),
|
|
}
|
|
}
|
|
|
|
/// Load a compressed object from an object stream (for lightweight metadata extraction)
|
|
fn get_compressed_object(&self, id: ObjectId, already_seen: &mut HashSet<ObjectId>) -> Result<Object> {
|
|
let entry = self.document.reference_table.get(id.0).ok_or(Error::MissingXrefEntry)?;
|
|
|
|
let container_id = match entry {
|
|
XrefEntry::Compressed { container, .. } => *container,
|
|
_ => return Err(Error::MissingXrefEntry),
|
|
};
|
|
|
|
// PdfCraft patch: require an in-file container (PDF 7.5.7) and retain the caller's cycle history through
|
|
// indirect stream /Length lookups; `get_object` caps the depth of that history.
|
|
if !matches!(
|
|
self.document.reference_table.get(container_id),
|
|
Some(XrefEntry::Normal { .. })
|
|
) {
|
|
return Err(Error::InvalidObjectStream(
|
|
"Object stream container must be an ordinary in-file object".into(),
|
|
));
|
|
}
|
|
let container_id = (container_id, 0);
|
|
let container_obj = self.get_object(container_id, already_seen)?;
|
|
let container_stream = container_obj.as_stream()?;
|
|
let object_stream = ObjectStream::new_with_limit(container_stream, self.max_decompressed_size)?;
|
|
object_stream.objects.get(&id).cloned().ok_or(Error::MissingXrefEntry)
|
|
}
|
|
|
|
pub fn get_object(&self, id: ObjectId, already_seen: &mut HashSet<ObjectId>) -> Result<Object> {
|
|
// PdfCraft patch: each top-level lookup starts with an empty set and every nested lookup adds one object, so
|
|
// the set's size is the depth of the lookup chain.
|
|
if already_seen.len() >= MAX_LOOKUP_DEPTH {
|
|
warn!("lookup chain deeper than {MAX_LOOKUP_DEPTH} at object {} {}", id.0, id.1);
|
|
return Err(Error::ReferenceLimit);
|
|
}
|
|
if already_seen.contains(&id) {
|
|
warn!("reference cycle detected resolving object {} {}", id.0, id.1);
|
|
return Err(Error::ReferenceCycle(id));
|
|
}
|
|
already_seen.insert(id);
|
|
|
|
if let Some(entry) = self.document.reference_table.get(id.0)
|
|
&& matches!(entry, XrefEntry::Compressed { .. })
|
|
{
|
|
return self.get_compressed_object(id, already_seen);
|
|
}
|
|
|
|
let offset = self.get_offset(id)?;
|
|
let (_, mut obj) = self.read_object(offset as usize, Some(id), already_seen)?;
|
|
|
|
if let Some(ref state) = self.encryption_state {
|
|
let encrypt_ref = self
|
|
.document
|
|
.trailer
|
|
.get(b"Encrypt")
|
|
.ok()
|
|
.and_then(|o| o.as_reference().ok());
|
|
if let Some(enc_ref) = encrypt_ref
|
|
&& id != enc_ref
|
|
{
|
|
encryption::decrypt_object(state, id, &mut obj).map_err(Error::Decryption)?;
|
|
}
|
|
}
|
|
|
|
Ok(obj)
|
|
}
|
|
|
|
fn parse_encryption_dictionary(&mut self) -> Result<()> {
|
|
if let Ok(encrypt_ref) = self.document.trailer.get(b"Encrypt").and_then(|o| o.as_reference()) {
|
|
if self.raw_objects.is_empty() {
|
|
let offset = self.get_offset(encrypt_ref)?;
|
|
let (_, encrypt_obj) = self.read_object(offset as usize, Some(encrypt_ref), &mut HashSet::new())?;
|
|
self.document.objects.insert(encrypt_ref, encrypt_obj);
|
|
} else if let Some(raw_bytes) = self.raw_objects.get(&encrypt_ref)
|
|
&& let Ok((_, obj)) = self.parse_raw_object(raw_bytes)
|
|
{
|
|
self.document.objects.insert(encrypt_ref, obj);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn authenticate_and_setup_encryption(&mut self, require_password: bool) -> Result<Option<String>> {
|
|
let password_to_use: Option<String> = if self.document.authenticate_password("").is_ok() {
|
|
Some(String::new())
|
|
} else if let Some(ref pwd) = self.password {
|
|
if self.document.authenticate_password(pwd).is_ok() {
|
|
Some(pwd.clone())
|
|
} else if require_password {
|
|
return Err(Error::InvalidPassword);
|
|
} else {
|
|
warn!("Invalid password provided for encrypted PDF");
|
|
return Err(Error::InvalidPassword);
|
|
}
|
|
} else if require_password {
|
|
return Err(Error::Unimplemented(
|
|
"PDF is encrypted and requires a password. Use Document::load_metadata_with_password() instead.",
|
|
));
|
|
} else {
|
|
warn!("PDF is encrypted and requires a password");
|
|
return Ok(None);
|
|
};
|
|
|
|
if let Some(ref password) = password_to_use {
|
|
let state = EncryptionState::decode(&self.document, password)?;
|
|
self.encryption_state = Some(state);
|
|
}
|
|
|
|
Ok(password_to_use)
|
|
}
|
|
|
|
fn setup_encryption_for_metadata(&mut self) -> Result<()> {
|
|
self.parse_encryption_dictionary()?;
|
|
self.authenticate_and_setup_encryption(true)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_raw_object(&mut self, offset: usize) -> Result<(ObjectId, Vec<u8>)> {
|
|
if offset > self.buffer.len() {
|
|
return Err(Error::InvalidOffset(offset));
|
|
}
|
|
|
|
// Find object header (e.g., "19 0 obj")
|
|
let slice = &self.buffer[offset..];
|
|
|
|
// Parse object ID
|
|
let mut pos = 0;
|
|
while pos < slice.len() && slice[pos].is_ascii_whitespace() {
|
|
pos += 1;
|
|
}
|
|
|
|
// Get object number
|
|
let num_start = pos;
|
|
while pos < slice.len() && slice[pos].is_ascii_digit() {
|
|
pos += 1;
|
|
}
|
|
let obj_num: u32 = std::str::from_utf8(&slice[num_start..pos])
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.ok_or(Error::Parse(ParseError::InvalidXref))?;
|
|
|
|
// Skip whitespace
|
|
while pos < slice.len() && slice[pos].is_ascii_whitespace() {
|
|
pos += 1;
|
|
}
|
|
|
|
// Get generation number
|
|
let gen_start = pos;
|
|
while pos < slice.len() && slice[pos].is_ascii_digit() {
|
|
pos += 1;
|
|
}
|
|
let obj_gen: u16 = std::str::from_utf8(&slice[gen_start..pos])
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.ok_or(Error::Parse(ParseError::InvalidXref))?;
|
|
|
|
// Skip to "obj"
|
|
while pos < slice.len() && slice[pos].is_ascii_whitespace() {
|
|
pos += 1;
|
|
}
|
|
if pos + 3 > slice.len() || &slice[pos..pos + 3] != b"obj" {
|
|
return Err(Error::Parse(ParseError::InvalidXref));
|
|
}
|
|
pos += 3;
|
|
|
|
// Find "endobj"
|
|
let endobj_pattern = b"endobj";
|
|
let mut end_pos = pos;
|
|
while end_pos + endobj_pattern.len() <= slice.len() {
|
|
if &slice[end_pos..end_pos + endobj_pattern.len()] == endobj_pattern {
|
|
end_pos += endobj_pattern.len();
|
|
break;
|
|
}
|
|
end_pos += 1;
|
|
}
|
|
|
|
if end_pos > slice.len() {
|
|
return Err(Error::Parse(ParseError::InvalidXref));
|
|
}
|
|
|
|
// Extract raw object bytes (including header and trailer)
|
|
let raw_bytes = slice[0..end_pos].to_vec();
|
|
|
|
Ok(((obj_num, obj_gen), raw_bytes))
|
|
}
|
|
|
|
fn read_object(
|
|
&self, offset: usize, expected_id: Option<ObjectId>, already_seen: &mut HashSet<ObjectId>,
|
|
) -> Result<(ObjectId, Object)> {
|
|
let next_index = self.normal_offsets.partition_point(|&next| next <= offset);
|
|
let end = self.object_end(offset, self.normal_offsets.get(next_index).copied());
|
|
self.read_object_to(offset, end, expected_id, already_seen)
|
|
}
|
|
|
|
fn object_end(&self, offset: usize, next_object: Option<usize>) -> usize {
|
|
let xref_start = (self.document.xref_start > offset).then_some(self.document.xref_start);
|
|
next_object
|
|
.into_iter()
|
|
.chain(xref_start)
|
|
.min()
|
|
.unwrap_or(self.buffer.len())
|
|
.min(self.buffer.len())
|
|
}
|
|
|
|
fn read_object_to(
|
|
&self, offset: usize, end: usize, expected_id: Option<ObjectId>, already_seen: &mut HashSet<ObjectId>,
|
|
) -> Result<(ObjectId, Object)> {
|
|
if offset > end || end > self.buffer.len() {
|
|
return Err(Error::InvalidOffset(offset));
|
|
}
|
|
|
|
// Objects are parsed against the full buffer so a wrong *neighbor*
|
|
// xref offset cannot truncate a well-formed object; `end` only limits
|
|
// how far malformed-stream length recovery may scan.
|
|
parser::indirect_object(self.buffer, offset, expected_id, self, already_seen, Some(end))
|
|
}
|
|
|
|
/// Parse the cross-reference section recorded at `offset`, first correcting
|
|
/// the offset if it is slightly miswritten (lenient mode only).
|
|
fn xref_and_trailer_at(&self, offset: usize) -> Result<(Xref, Dictionary)> {
|
|
let offset = self.correct_xref_offset(offset);
|
|
parser::xref_and_trailer(&self.buffer[offset..], self)
|
|
}
|
|
|
|
/// Resolve the cross-reference table/stream and trailer, including the
|
|
/// `/Prev` chain, and record the resolved start offset on the document.
|
|
fn resolve_xref_and_trailer(&mut self) -> Result<(Xref, Dictionary)> {
|
|
let xref_start = Self::get_xref_start(self.buffer)?;
|
|
if xref_start > self.buffer.len() {
|
|
return Err(Error::Xref(XrefError::Start));
|
|
}
|
|
let xref_start = self.correct_xref_offset(xref_start);
|
|
self.document.xref_start = xref_start;
|
|
|
|
let (mut xref, mut trailer) = self.xref_and_trailer_at(xref_start)?;
|
|
|
|
// Read previous Xrefs of linearized or incremental updated document.
|
|
let mut already_seen = HashSet::new();
|
|
let mut prev_xref_start = trailer.remove(b"Prev");
|
|
while let Some(prev) = prev_xref_start.and_then(|offset| offset.as_i64().ok()) {
|
|
if already_seen.contains(&prev) {
|
|
break;
|
|
}
|
|
already_seen.insert(prev);
|
|
if prev < 0 || prev as usize > self.buffer.len() {
|
|
return Err(Error::Xref(XrefError::PrevStart));
|
|
}
|
|
|
|
let (prev_xref, prev_trailer) = self.xref_and_trailer_at(prev as usize)?;
|
|
xref.merge(prev_xref);
|
|
|
|
// Read xref stream in hybrid-reference file
|
|
let prev_xref_stream_start = trailer.remove(b"XRefStm");
|
|
if let Some(prev) = prev_xref_stream_start.and_then(|offset| offset.as_i64().ok()) {
|
|
if prev < 0 || prev as usize > self.buffer.len() {
|
|
return Err(Error::Xref(XrefError::StreamStart));
|
|
}
|
|
|
|
let (prev_xref, _) = self.xref_and_trailer_at(prev as usize)?;
|
|
xref.merge(prev_xref);
|
|
}
|
|
|
|
prev_xref_start = prev_trailer.get(b"Prev").cloned().ok();
|
|
}
|
|
let xref_entry_count = xref.max_id().checked_add(1).ok_or(ParseError::InvalidXref)?;
|
|
if xref.size != xref_entry_count {
|
|
warn!(
|
|
"Size entry of trailer dictionary is {}, correct value is {}.",
|
|
xref.size, xref_entry_count
|
|
);
|
|
xref.size = xref_entry_count;
|
|
}
|
|
|
|
Ok((xref, trailer))
|
|
}
|
|
|
|
/// Last-resort recovery: rebuild the cross-reference table by scanning the
|
|
/// raw bytes for indirect-object headers and locating a trailer with a
|
|
/// usable `/Root`. Returns `None` (caller keeps the original error) when
|
|
/// strict mode forbids recovery or nothing usable was found.
|
|
fn reconstruct_xref_and_trailer(&mut self) -> Option<(Xref, Dictionary)> {
|
|
if self.strict {
|
|
return None;
|
|
}
|
|
u32::try_from(self.buffer.len()).ok()?;
|
|
|
|
let markers = Self::scan_object_markers(self.buffer);
|
|
if markers.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut xref = Xref::new(markers.len() as u32, XrefType::CrossReferenceTable);
|
|
for (offset, (number, generation)) in markers {
|
|
// Incremental updates append, so later revisions win.
|
|
xref.insert(number, XrefEntry::Normal { offset, generation });
|
|
}
|
|
// Normalize like `resolve_xref_and_trailer`: size spans object numbers
|
|
// up to the highest, not physical copies across incremental updates.
|
|
xref.size = xref.max_id().saturating_add(1);
|
|
|
|
let (_trailer_pos, trailer) = self.find_latest_trailer(&xref)?;
|
|
// deliberate: no on-disk table exists to point at. Zero marks the
|
|
// offset "unknown" so `Document::new_from_prev` omits `/Prev` instead
|
|
// of recording end-of-file; `object_end` clamps to the buffer either way.
|
|
self.document.xref_start = 0;
|
|
|
|
warn!(
|
|
"reconstructed cross-reference table with {} objects by scanning for indirect objects",
|
|
xref.entries.len()
|
|
);
|
|
Some((xref, trailer))
|
|
}
|
|
|
|
/// Collect `(offset, id)` of every indirect-object header in one pass.
|
|
/// Only headers starting a line (optional leading blanks allowed) count,
|
|
/// stream payloads are skipped wholesale, and object numbers beyond
|
|
/// [`MAX_RECONSTRUCTED_OBJECTS`] are rejected so a forged header can
|
|
/// neither shadow a genuine entry nor poison the reconstructed size.
|
|
fn scan_object_markers(buffer: &[u8]) -> Vec<(u32, ObjectId)> {
|
|
const STREAM_KEYWORD: &[u8] = b"stream";
|
|
const END_STREAM_KEYWORD: &[u8] = b"endstream";
|
|
|
|
let mut markers = Vec::new();
|
|
let mut oversized_number_warned = false;
|
|
let mut at_line_start = true;
|
|
let mut pos = 0;
|
|
while pos < buffer.len() {
|
|
// Skip raw stream data: an uncompressed payload may embed
|
|
// convincing `N G obj` lines whose later offsets would otherwise
|
|
// override the genuine entries for those object numbers.
|
|
if buffer[pos..].starts_with(STREAM_KEYWORD)
|
|
&& !buffer[..pos].ends_with(b"end")
|
|
&& matches!(buffer.get(pos + STREAM_KEYWORD.len()), Some(b'\r' | b'\n'))
|
|
{
|
|
let after_keyword = pos + STREAM_KEYWORD.len();
|
|
if let Some(relative) = buffer[after_keyword..]
|
|
.windows(END_STREAM_KEYWORD.len())
|
|
.position(|window| window == END_STREAM_KEYWORD)
|
|
{
|
|
pos = after_keyword + relative + END_STREAM_KEYWORD.len();
|
|
at_line_start = false;
|
|
continue;
|
|
}
|
|
// Damaged stream without terminator: fall back to the
|
|
// dictionary's /Length hint so the payload cannot hide
|
|
// line-start pseudo headers, while objects written after it
|
|
// stay reachable.
|
|
if let Some(resume) = Self::payload_end_by_length(buffer, pos) {
|
|
pos = resume;
|
|
at_line_start = false;
|
|
continue;
|
|
}
|
|
// Unusable /Length too: nothing bounds the payload, so keep
|
|
// scanning byte by byte rather than dropping what follows.
|
|
}
|
|
// Anchor at line starts so pseudo headers buried after another
|
|
// token (string literal, comment) are never mistaken for markers.
|
|
if at_line_start
|
|
&& buffer[pos].is_ascii_digit()
|
|
&& let Some(id) = Self::parse_object_header(&buffer[pos..])
|
|
{
|
|
// A huge bogus number would inflate `size` (and thus
|
|
// `max_id`) via `Xref::insert`; genuine numbering stays
|
|
// within the same cap as the marker count.
|
|
if id.0 > MAX_RECONSTRUCTED_OBJECTS as u32 {
|
|
if !oversized_number_warned {
|
|
warn!("ignoring object headers numbered above {MAX_RECONSTRUCTED_OBJECTS}");
|
|
oversized_number_warned = true;
|
|
}
|
|
} else {
|
|
if markers.len() == MAX_RECONSTRUCTED_OBJECTS {
|
|
warn!(
|
|
"object marker scan stopped at the {MAX_RECONSTRUCTED_OBJECTS}-marker cap; reconstruction may be incomplete"
|
|
);
|
|
break;
|
|
}
|
|
markers.push((pos as u32, id));
|
|
}
|
|
}
|
|
match buffer[pos] {
|
|
b'\r' | b'\n' => at_line_start = true,
|
|
b' ' | b'\t' => {}
|
|
_ => at_line_start = false,
|
|
}
|
|
pos += 1;
|
|
}
|
|
markers
|
|
}
|
|
|
|
/// Resume offset past a stream payload according to a *direct* `/Length`
|
|
/// integer in the dictionary preceding the `stream` keyword at
|
|
/// `stream_pos`. The search is scoped to the current object (after the
|
|
/// nearest preceding `obj` keyword) so a missing `/Length` cannot latch
|
|
/// onto a previous object's value. Returns `None` when the hint is
|
|
/// absent, indirect, or points outside the buffer — damaged files tend
|
|
/// to carry wrong `/Length` values, so it must stay a hint, never a hard
|
|
/// boundary.
|
|
fn payload_end_by_length(buffer: &[u8], stream_pos: usize) -> Option<usize> {
|
|
const LENGTH_KEY: &[u8] = b"/Length";
|
|
const OBJ_KEYWORD: &[u8] = b"obj";
|
|
const STREAM_KEYWORD_LEN: usize = b"stream".len();
|
|
|
|
let head = &buffer[..stream_pos];
|
|
let obj_pos = head
|
|
.windows(OBJ_KEYWORD.len())
|
|
.rposition(|window| window == OBJ_KEYWORD)?;
|
|
let dict_region = &buffer[obj_pos + OBJ_KEYWORD.len()..stream_pos];
|
|
// Nearest `/Length` in this object's dictionary; parsing it validates
|
|
// that the match really is a key with an integer value.
|
|
let key_pos = dict_region
|
|
.windows(LENGTH_KEY.len())
|
|
.rposition(|window| window == LENGTH_KEY)?;
|
|
let rest = &dict_region[key_pos + LENGTH_KEY.len()..];
|
|
let digits_start = rest.iter().position(u8::is_ascii_digit)?;
|
|
let digits_len = rest[digits_start..]
|
|
.iter()
|
|
.take_while(|byte| byte.is_ascii_digit())
|
|
.count();
|
|
if digits_len > 10 {
|
|
return None;
|
|
}
|
|
// An indirect reference (`/Length 5 0 R`) continues with another
|
|
// number token; a direct integer ends at a name, `>>`, or the keyword.
|
|
match rest[digits_start + digits_len..]
|
|
.iter()
|
|
.copied()
|
|
.find(|byte| !byte.is_ascii_whitespace())
|
|
{
|
|
Some(b'/') | Some(b'>') => {}
|
|
_ => return None,
|
|
}
|
|
let length: usize = std::str::from_utf8(&rest[digits_start..digits_start + digits_len])
|
|
.ok()?
|
|
.parse()
|
|
.ok()?;
|
|
// Spec: the EOL after the `stream` keyword counts as CRLF or LF.
|
|
let eol_len = match (
|
|
buffer.get(stream_pos + STREAM_KEYWORD_LEN),
|
|
buffer.get(stream_pos + STREAM_KEYWORD_LEN + 1),
|
|
) {
|
|
(Some(b'\r'), Some(b'\n')) => 2,
|
|
_ => 1,
|
|
};
|
|
let end = (stream_pos + STREAM_KEYWORD_LEN + eol_len).checked_add(length)?;
|
|
(buffer.len() >= end).then_some(end)
|
|
}
|
|
|
|
/// Parse an `N G obj` header; the byte after `obj` must end the token.
|
|
fn parse_object_header(input: &[u8]) -> Option<ObjectId> {
|
|
// deliberate: caps at u32/u16 width bound work on pathological padding.
|
|
fn digits(input: &[u8], cap: usize) -> Option<(&[u8], &[u8])> {
|
|
let n = input.iter().take_while(|b| b.is_ascii_digit()).count();
|
|
(0 < n && n <= cap).then(|| input.split_at(n))
|
|
}
|
|
fn spaces(input: &[u8]) -> Option<&[u8]> {
|
|
let n = input
|
|
.iter()
|
|
.take_while(|&&b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
|
|
.count();
|
|
(n > 0).then(|| &input[n..])
|
|
}
|
|
|
|
let (number, rest) = digits(input, 10)?;
|
|
let number: u32 = std::str::from_utf8(number).ok()?.parse().ok()?;
|
|
let rest = spaces(rest)?;
|
|
let (generation, rest) = digits(rest, 5)?;
|
|
let generation: u16 = std::str::from_utf8(generation).ok()?.parse().ok()?;
|
|
let rest = spaces(rest)?;
|
|
let tail = rest.strip_prefix(b"obj")?;
|
|
match tail.first() {
|
|
None => {}
|
|
Some(&byte) if !byte.is_ascii_alphanumeric() => {}
|
|
_ => return None,
|
|
}
|
|
Some((number, generation))
|
|
}
|
|
|
|
/// Newest backwards-scanned `trailer` whose `/Root` references a scanned object, one whose
|
|
/// `/Root` is the catalog first.
|
|
fn find_latest_trailer(&self, xref: &Xref) -> Option<(usize, Dictionary)> {
|
|
// PdfCraft patch: (#2365) an in-file /Root must also match its generation, as
|
|
// `get_offset` requires when the root is loaded.
|
|
let usable = |dict: &Dictionary| {
|
|
dict.get(b"Root")
|
|
.and_then(Object::as_reference)
|
|
.is_ok_and(|root| match xref.entries.get(&root.0) {
|
|
Some(XrefEntry::Normal { generation, .. }) => *generation == root.1,
|
|
entry => entry.is_some(),
|
|
})
|
|
};
|
|
// PdfCraft patch: (#2365) a candidate whose /Root is the catalog (`/Type /Catalog` or
|
|
// `/Pages`) beats one whose /Root is another dictionary, such as /Info, wherever it is;
|
|
// the other kind is still taken when nothing better is there (an FDF's /Root).
|
|
let is_catalog = |dict: &Dictionary| {
|
|
let Ok(root) = dict.get(b"Root").and_then(Object::as_reference) else {
|
|
return false;
|
|
};
|
|
let Some(XrefEntry::Normal { offset, generation }) = xref.entries.get(&root.0) else {
|
|
return false;
|
|
};
|
|
if *generation != root.1 {
|
|
return false;
|
|
}
|
|
parser::indirect_object(self.buffer, *offset as usize, None, self, &mut HashSet::new(), None)
|
|
.ok()
|
|
.and_then(|(_, object)| object.as_dict().map(|d| d.has_type(b"Catalog") || d.has(b"Pages")).ok())
|
|
.unwrap_or(false)
|
|
};
|
|
let mut latest: Option<(bool, usize, Dictionary)> = None;
|
|
let mut bound = self.buffer.len();
|
|
for _ in 0..MAX_TRAILER_CANDIDATES {
|
|
let Some(pos) = Reader::search_substring(&self.buffer[..bound], b"trailer", 0) else {
|
|
break;
|
|
};
|
|
bound = pos;
|
|
|
|
let after_keyword = &self.buffer[pos + b"trailer".len()..];
|
|
let Some(dict_start) = after_keyword.iter().position(|byte| !byte.is_ascii_whitespace()) else {
|
|
continue;
|
|
};
|
|
let Ok((_, dict)) = parser::dictionary(&after_keyword[dict_start..]) else {
|
|
continue;
|
|
};
|
|
if usable(&dict) {
|
|
let catalog = is_catalog(&dict);
|
|
if latest.is_none() || catalog {
|
|
latest = Some((catalog, pos, dict));
|
|
}
|
|
if catalog {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// PdfCraft patch: (#2365) a file whose only trailer is a cross-reference stream has
|
|
// no `trailer` keyword, so the scan above found nothing and the rebuild failed; its
|
|
// /Info and /ID were then lost. Such a stream's dictionary is the trailer, and the
|
|
// latest one in the file wins, as a later `trailer` keyword would.
|
|
const HEADER_WINDOW: usize = 32;
|
|
const DICT_WINDOW: usize = 4096;
|
|
for entry in xref.entries.values() {
|
|
let XrefEntry::Normal { offset, .. } = entry else {
|
|
continue;
|
|
};
|
|
let offset = *offset as usize;
|
|
if latest.as_ref().is_some_and(|(catalog, pos, _)| *catalog && *pos >= offset) {
|
|
continue;
|
|
}
|
|
let Some(head) = self.buffer.get(offset..) else {
|
|
continue;
|
|
};
|
|
let Some(dict_start) = head[..cmp::min(head.len(), HEADER_WINDOW)]
|
|
.windows(2)
|
|
.position(|window| window == b"<<")
|
|
else {
|
|
continue;
|
|
};
|
|
let dict_bytes = &head[dict_start..cmp::min(head.len(), dict_start + DICT_WINDOW)];
|
|
if !dict_bytes.windows(5).any(|window| window == b"/XRef") {
|
|
continue;
|
|
}
|
|
let Ok((_, dict)) = parser::dictionary(&head[dict_start..]) else {
|
|
continue;
|
|
};
|
|
if dict.has_type(b"XRef") && usable(&dict) {
|
|
let rank = (is_catalog(&dict), offset);
|
|
if latest.as_ref().is_none_or(|(catalog, pos, _)| rank > (*catalog, *pos)) {
|
|
latest = Some((rank.0, offset, dict));
|
|
}
|
|
}
|
|
}
|
|
latest.map(|(_, pos, dict)| (pos, dict))
|
|
}
|
|
|
|
/// Some generators write `startxref` (or trailer `Prev`) values that are
|
|
/// slightly off — most commonly the offset of the line *after* the `xref`
|
|
/// keyword instead of the keyword itself. Such files are otherwise intact,
|
|
/// and common readers (Acrobat, poppler, mupdf, pdf.js) recover from them.
|
|
/// In lenient mode, if `offset` does not point at a cross-reference table
|
|
/// or an indirect object (cross-reference stream), scan a small window
|
|
/// around it for the `xref` keyword and use the nearest match instead.
|
|
fn correct_xref_offset(&self, offset: usize) -> usize {
|
|
const RECOVERY_WINDOW: usize = 64;
|
|
|
|
if self.strict || offset >= self.buffer.len() {
|
|
return offset;
|
|
}
|
|
let rest = &self.buffer[offset..];
|
|
if rest.starts_with(b"xref") || Self::starts_indirect_object(rest) {
|
|
return offset;
|
|
}
|
|
|
|
let window_start = offset.saturating_sub(RECOVERY_WINDOW);
|
|
let window_end = cmp::min(self.buffer.len(), offset + RECOVERY_WINDOW);
|
|
let mut corrected: Option<usize> = None;
|
|
for pos in window_start..window_end.saturating_sub(4) {
|
|
if !self.buffer[pos..].starts_with(b"xref") {
|
|
continue;
|
|
}
|
|
// `startxref` contains `xref`; never match inside it.
|
|
if pos >= 5 && &self.buffer[pos - 5..pos] == b"start" {
|
|
continue;
|
|
}
|
|
if corrected.is_none_or(|best: usize| pos.abs_diff(offset) < best.abs_diff(offset)) {
|
|
corrected = Some(pos);
|
|
}
|
|
}
|
|
match corrected {
|
|
Some(pos) => {
|
|
warn!(
|
|
"Cross-reference offset {} does not point at an xref section; using nearby offset {} instead.",
|
|
offset, pos
|
|
);
|
|
pos
|
|
}
|
|
None => offset,
|
|
}
|
|
}
|
|
|
|
/// Whether `input` begins with an indirect-object header (`N G obj`), the
|
|
/// form a cross-reference stream starts with.
|
|
fn starts_indirect_object(input: &[u8]) -> bool {
|
|
Self::parse_object_header(input).is_some()
|
|
}
|
|
|
|
fn get_xref_start(buffer: &[u8]) -> Result<usize> {
|
|
let seek_pos = buffer.len() - cmp::min(buffer.len(), 512);
|
|
Self::search_substring(buffer, b"%%EOF", seek_pos)
|
|
.filter(|&eof_pos| eof_pos > 25)
|
|
.and_then(|eof_pos| Self::search_substring(&buffer[..eof_pos], b"startxref", eof_pos - 25))
|
|
.ok_or(Error::Xref(XrefError::Start))
|
|
.and_then(|xref_pos| {
|
|
if xref_pos <= buffer.len() {
|
|
match parser::xref_start(&buffer[xref_pos..]) {
|
|
Some(startxref) => Ok(startxref as usize),
|
|
None => Err(Error::Xref(XrefError::Start)),
|
|
}
|
|
} else {
|
|
Err(Error::Xref(XrefError::Start))
|
|
}
|
|
})
|
|
}
|
|
|
|
fn search_substring(buffer: &[u8], pattern: &[u8], start_pos: usize) -> Option<usize> {
|
|
buffer
|
|
.get(start_pos..)?
|
|
.windows(pattern.len())
|
|
.rposition(|window| window == pattern)
|
|
.map(|pos| start_pos + pos)
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, not(feature = "async")))]
|
|
#[test]
|
|
fn load_document() {
|
|
let mut doc = Document::load("assets/example.pdf").unwrap();
|
|
assert_eq!(doc.version, "1.5");
|
|
|
|
// Create temporary folder to store file.
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test_2_load.pdf");
|
|
doc.save(file_path).unwrap();
|
|
}
|
|
|
|
#[cfg(all(test, feature = "async"))]
|
|
#[tokio::test]
|
|
async fn load_document() {
|
|
let mut doc = Document::load("assets/example.pdf").await.unwrap();
|
|
assert_eq!(doc.version, "1.5");
|
|
|
|
// Create temporary folder to store file.
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = temp_dir.path().join("test_2_load.pdf");
|
|
doc.save(file_path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "Xref(Start)")]
|
|
fn load_short_document() {
|
|
let _doc = Document::load_mem(b"%PDF-1.5\n%%EOF\n").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn load_document_with_preceding_bytes() {
|
|
let mut content = Vec::new();
|
|
content.extend(b"garbage");
|
|
content.extend(include_bytes!("../assets/example.pdf"));
|
|
let doc = Document::load_mem(&content).unwrap();
|
|
assert_eq!(doc.version, "1.5");
|
|
}
|
|
|
|
#[test]
|
|
fn load_many_shallow_brackets() {
|
|
let content: String = std::iter::repeat_n("()", MAX_BRACKET * 10)
|
|
.flat_map(|x| x.chars())
|
|
.collect();
|
|
const STREAM_CRUFT: usize = 33;
|
|
let doc = format!(
|
|
"%PDF-1.5
|
|
1 0 obj<</Type/Pages/Kids[5 0 R]/Count 1/Resources 3 0 R/MediaBox[0 0 595 842]>>endobj
|
|
2 0 obj<</Type/Font/Subtype/Type1/BaseFont/Courier>>endobj
|
|
3 0 obj<</Font<</F1 2 0 R>>>>endobj
|
|
5 0 obj<</Type/Page/Parent 1 0 R/Contents[4 0 R]>>endobj
|
|
6 0 obj<</Type/Catalog/Pages 1 0 R>>endobj
|
|
4 0 obj<</Length {}>>stream
|
|
BT
|
|
/F1 48 Tf
|
|
100 600 Td
|
|
({}) Tj
|
|
ET
|
|
endstream endobj\n",
|
|
content.len() + STREAM_CRUFT,
|
|
content
|
|
);
|
|
let doc = format!(
|
|
"{}xref
|
|
0 7
|
|
0000000000 65535 f
|
|
0000000009 00000 n
|
|
0000000096 00000 n
|
|
0000000155 00000 n
|
|
0000000291 00000 n
|
|
0000000191 00000 n
|
|
0000000248 00000 n
|
|
trailer
|
|
<</Root 6 0 R/Size 7>>
|
|
startxref
|
|
{}
|
|
%%EOF",
|
|
doc,
|
|
doc.len()
|
|
);
|
|
|
|
let _doc = Document::load_mem(doc.as_bytes()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn load_too_deep_brackets() {
|
|
let content: Vec<u8> = std::iter::repeat_n(b'(', MAX_BRACKET + 1)
|
|
.chain(std::iter::repeat_n(b')', MAX_BRACKET + 1))
|
|
.collect();
|
|
let content = String::from_utf8(content).unwrap();
|
|
const STREAM_CRUFT: usize = 33;
|
|
let doc = format!(
|
|
"%PDF-1.5
|
|
1 0 obj<</Type/Pages/Kids[5 0 R]/Count 1/Resources 3 0 R/MediaBox[0 0 595 842]>>endobj
|
|
2 0 obj<</Type/Font/Subtype/Type1/BaseFont/Courier>>endobj
|
|
3 0 obj<</Font<</F1 2 0 R>>>>endobj
|
|
5 0 obj<</Type/Page/Parent 1 0 R/Contents[7 0 R 4 0 R]>>endobj
|
|
6 0 obj<</Type/Catalog/Pages 1 0 R>>endobj
|
|
7 0 obj<</Length 45>>stream
|
|
BT /F1 48 Tf 100 600 Td (Hello World!) Tj ET
|
|
endstream
|
|
endobj
|
|
4 0 obj<</Length {}>>stream
|
|
BT
|
|
/F1 48 Tf
|
|
100 600 Td
|
|
({}) Tj
|
|
ET
|
|
endstream endobj\n",
|
|
content.len() + STREAM_CRUFT,
|
|
content
|
|
);
|
|
let doc = format!(
|
|
"{}xref
|
|
0 7
|
|
0000000000 65535 f
|
|
0000000009 00000 n
|
|
0000000096 00000 n
|
|
0000000155 00000 n
|
|
0000000387 00000 n
|
|
0000000191 00000 n
|
|
0000000254 00000 n
|
|
0000000297 00000 n
|
|
trailer
|
|
<</Root 6 0 R/Size 7>>
|
|
startxref
|
|
{}
|
|
%%EOF",
|
|
doc,
|
|
doc.len()
|
|
);
|
|
|
|
let doc = Document::load_mem(doc.as_bytes()).unwrap();
|
|
let pages = doc.get_pages().keys().cloned().collect::<Vec<_>>();
|
|
assert_eq!("Hello World!\n", doc.extract_text(&pages).unwrap());
|
|
}
|
|
|
|
#[cfg(all(test, not(feature = "async")))]
|
|
#[test]
|
|
fn search_substring_finds_last_occurrence() {
|
|
assert_eq!(Reader::search_substring(b"hello world", b"xyz", 0), None);
|
|
assert_eq!(Reader::search_substring(b"hello world", b"world", 0), Some(6));
|
|
|
|
let buffer = b"%%EOF\ntest%%EOF\nend";
|
|
assert_eq!(Reader::search_substring(buffer, b"%%EOF", 0), Some(10));
|
|
assert_eq!(Reader::search_substring(buffer, b"%%EOF", 6), Some(10));
|
|
assert_eq!(Reader::search_substring(buffer, b"%%EOF", 15), None);
|
|
assert_eq!(Reader::search_substring(b"%%EOF", b"%%EOF", 0), Some(0));
|
|
|
|
let buffer_with_many_percents = b"%%%PDF-1.3%%%comment%%%more%%EOF";
|
|
assert_eq!(
|
|
Reader::search_substring(buffer_with_many_percents, b"%%EOF", 0),
|
|
Some(27)
|
|
);
|
|
}
|
|
|
|
#[cfg(all(test, not(feature = "async")))]
|
|
#[test]
|
|
fn get_xref_start_ignores_startxref_past_eof() {
|
|
// Simulate a PDF with two revisions where the second has a corrupted %%EOF.
|
|
// The valid %%EOF is at a known position; a second startxref appears after it
|
|
// but belongs to the corrupted revision. get_xref_start must pick the
|
|
// startxref *before* the valid %%EOF.
|
|
let mut buf = Vec::new();
|
|
// Padding so the buffer is large enough
|
|
buf.extend_from_slice(&[b' '; 200]);
|
|
// First (correct) startxref + xref offset + valid %%EOF
|
|
let xref_offset = 100usize;
|
|
let startxref_block = format!("startxref\n{}\n%%EOF\n", xref_offset);
|
|
let _startxref_pos = buf.len();
|
|
buf.extend_from_slice(startxref_block.as_bytes());
|
|
// Second (corrupted) revision: another startxref pointing elsewhere, with %%EO\0
|
|
let bad_block = "startxref\n999\n%%EO\x00\n".to_string();
|
|
buf.extend_from_slice(bad_block.as_bytes());
|
|
|
|
let result = Reader::get_xref_start(&buf).unwrap();
|
|
// Should find the xref_offset from the first startxref (before valid %%EOF)
|
|
assert_eq!(result, xref_offset);
|
|
// Verify it did NOT pick up 999 from the corrupted revision
|
|
assert_ne!(result, 999);
|
|
}
|
|
|
|
#[cfg(all(test, not(feature = "async")))]
|
|
#[test]
|
|
fn recovers_from_miswritten_startxref_offset() {
|
|
// Some generators write the offset of the line *after* the `xref` keyword
|
|
// into `startxref` instead of the offset of the keyword itself. Lenient
|
|
// loading must recover; strict loading must still reject the file.
|
|
let header = "%PDF-1.5\n";
|
|
let obj1 = "1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n";
|
|
let obj2 = "2 0 obj<</Type/Pages/Kids[]/Count 0>>endobj\n";
|
|
let o1 = header.len();
|
|
let o2 = o1 + obj1.len();
|
|
let body = format!("{header}{obj1}{obj2}");
|
|
let xref_pos = body.len();
|
|
let doc = format!(
|
|
"{body}xref
|
|
0 3
|
|
0000000000 65535 f
|
|
{o1:010} 00000 n
|
|
{o2:010} 00000 n
|
|
trailer
|
|
<</Root 1 0 R/Size 3>>
|
|
startxref
|
|
{}
|
|
%%EOF",
|
|
xref_pos + 4 // past the `xref` keyword, onto the line after it
|
|
);
|
|
|
|
let loaded = Document::load_mem(doc.as_bytes()).unwrap();
|
|
assert!(loaded.trailer.get(b"Root").is_ok());
|
|
assert_eq!(loaded.xref_start, xref_pos);
|
|
|
|
let strict = Document::load_mem_with_options(
|
|
doc.as_bytes(),
|
|
LoadOptions {
|
|
strict: true,
|
|
..Default::default()
|
|
},
|
|
);
|
|
assert!(strict.is_err());
|
|
}
|
|
|
|
#[cfg(all(test, not(feature = "async")))]
|
|
#[test]
|
|
fn stream_length_written_as_real() {
|
|
// ISO 32000-1 s7.3.8.2 requires /Length to be an integer, but some generators
|
|
// emit "/Length 42." instead of "/Length 42". Such a stream must still resolve
|
|
// to its content rather than silently loading as empty.
|
|
let obj4 = "4 0 obj\n<< /Length 42. >>\nstream\nBT /F1 12 Tf 20 100 Td (Hello World) Tj ET\nendstream\nendobj\n";
|
|
let header = "%PDF-1.7\n";
|
|
let obj1 = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
|
|
let obj2 = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
|
|
let obj3 = "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>\nendobj\n";
|
|
let o1 = header.len();
|
|
let o2 = o1 + obj1.len();
|
|
let o3 = o2 + obj2.len();
|
|
let o4 = o3 + obj3.len();
|
|
let body = format!("{header}{obj1}{obj2}{obj3}{obj4}");
|
|
let xref_pos = body.len();
|
|
let doc = format!(
|
|
"{body}xref
|
|
0 5
|
|
0000000000 65535 f
|
|
{o1:010} 00000 n
|
|
{o2:010} 00000 n
|
|
{o3:010} 00000 n
|
|
{o4:010} 00000 n
|
|
trailer
|
|
<< /Size 5 /Root 1 0 R >>
|
|
startxref
|
|
{xref_pos}
|
|
%%EOF
|
|
"
|
|
);
|
|
|
|
let loaded = Document::load_mem(doc.as_bytes()).unwrap();
|
|
let stream = loaded.get_object((4, 0)).unwrap().as_stream().unwrap();
|
|
assert_eq!(stream.content, b"BT /F1 12 Tf 20 100 Td (Hello World) Tj ET");
|
|
}
|
|
|
|
#[cfg(all(test, not(feature = "async")))]
|
|
#[test]
|
|
fn object_marker_parsing_accepts_only_real_headers() {
|
|
assert_eq!(Reader::parse_object_header(b"12 0 obj<<"), Some((12, 0)));
|
|
assert_eq!(Reader::parse_object_header(b"007 0 obj\n"), Some((7, 0)));
|
|
assert_eq!(Reader::parse_object_header(b"5 2 obj>>"), Some((5, 2)));
|
|
assert_eq!(Reader::parse_object_header(b"12\t3\r\nobj["), Some((12, 3)));
|
|
assert_eq!(Reader::parse_object_header(b"2 0 objects"), None);
|
|
assert_eq!(Reader::parse_object_header(b"99 88 objx"), None);
|
|
assert_eq!(Reader::parse_object_header(b"12345678901 0 obj"), None);
|
|
assert_eq!(Reader::parse_object_header(b"1 70000 obj"), None);
|
|
assert_eq!(Reader::parse_object_header(b"1 0obj"), None);
|
|
assert_eq!(Reader::parse_object_header(b"1 obj"), None);
|
|
assert_eq!(Reader::parse_object_header(b"x 0 obj"), None);
|
|
}
|