use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use bytes::Bytes;
use pdfboss_core::elements::{ElementOpts, Span, XrefKind};
use pdfboss_core::lexer::{Lexer, Token};
use pdfboss_core::object::decode_text_string;
use pdfboss_core::parser::{NoResolve, Parser, Resolve};
use pdfboss_core::xref::XrefEntry;
use pdfboss_core::{AsyncObjectSource, Dict, Metadata, ObjRef, Object, Page, Stream};
use crate::backend::{Backend, BoxFuture, FileBackend, MemBackend};
use crate::cache::CachedBackend;
use crate::error::{Error, Result};
const TAIL_WINDOW: u64 = 4096;
const MAX_TAIL_WINDOW: u64 = 64 * 1024;
const MAX_GROWTH_WINDOW: u64 = 256 * 1024 * 1024;
pub(crate) struct Fetcher {
pub(crate) backend: Arc<dyn Backend>,
pub(crate) len: u64,
}
impl Fetcher {
pub(crate) async fn read_range(&self, start: u64, end: u64) -> Result<Vec<u8>> {
let wanted = usize::try_from(end.saturating_sub(start)).map_err(|overflow| {
Error::Core(pdfboss_core::Error::Other(format!(
"range {start}..{end} does not fit this platform: {overflow}"
)))
})?;
let mut buf = vec![0u8; wanted];
let mut filled = 0;
while filled < wanted {
let got = self
.backend
.read_at(start + filled as u64, &mut buf[filled..])
.await
.map_err(Error::from)?;
if got == 0 {
return Err(Error::TruncatedRead {
offset: start,
wanted,
got: filled,
});
}
filled += got;
}
Ok(buf)
}
pub(crate) async fn window(&self, offset: u64, window: usize) -> Result<Vec<u8>> {
let end = self.len.min(offset.saturating_add(window as u64));
if offset >= end {
return Ok(Vec::new());
}
self.read_range(offset, end).await
}
}
pub(crate) fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack.windows(needle.len()).position(|w| w == needle)
}
pub(crate) fn rfind_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack.windows(needle.len()).rposition(|w| w == needle)
}
pub(crate) struct StartXrefRecord {
pub(crate) offset: u64,
pub(crate) span: Span,
}
pub(crate) async fn find_tail(fetcher: &Fetcher) -> Result<(StartXrefRecord, Option<Span>)> {
let mut window = TAIL_WINDOW;
loop {
let start = fetcher.len.saturating_sub(window);
let tail = fetcher.read_range(start, fetcher.len).await?;
if let Some(rel) = rfind_bytes(&tail, b"startxref") {
let mut lexer = Lexer::at(&tail, rel + b"startxref".len());
if let Ok(Token::Int(value)) = lexer.next_token() {
if value >= 0 && (value as u64) < fetcher.len {
let record = StartXrefRecord {
offset: value as u64,
span: Span {
start: start + rel as u64,
end: start + lexer.pos() as u64,
},
};
let eof = rfind_bytes(&tail, b"%%EOF").map(|pos| Span {
start: start + pos as u64,
end: start + pos as u64 + 5,
});
return Ok((record, eof));
}
}
}
if window >= fetcher.len || window >= MAX_TAIL_WINDOW {
return Err(Error::Core(pdfboss_core::Error::InvalidXref));
}
window *= 2;
}
}
pub(crate) fn parse_version(head: &[u8]) -> (u8, u8) {
try_parse_version(head).unwrap_or((1, 4))
}
fn try_parse_version(head: &[u8]) -> Option<(u8, u8)> {
let window = &head[..head.len().min(1024)];
let pos = find_bytes(window, b"%PDF-")?;
let rest = &window[pos + 5..];
let (major, used) = read_version_component(rest)?;
if rest.get(used) != Some(&b'.') {
return None;
}
let minor = read_version_component(&rest[used + 1..])?.0;
Some((major, minor))
}
fn read_version_component(bytes: &[u8]) -> Option<(u8, usize)> {
let end = bytes
.iter()
.position(|b| !b.is_ascii_digit())
.unwrap_or(bytes.len());
if end == 0 || end > 3 {
return None;
}
let value = std::str::from_utf8(&bytes[..end]).ok()?.parse().ok()?;
Some((value, end))
}
pub(crate) fn header_span_in(head: &[u8]) -> Option<Span> {
let window = &head[..head.len().min(1024)];
let pos = find_bytes(window, b"%PDF-")?;
let version_end = window[pos + 5..]
.iter()
.position(|&b| !(b.is_ascii_digit() || b == b'.'))
.map(|rel| pos + 5 + rel)
.unwrap_or(window.len());
Some(Span {
start: pos as u64,
end: version_end as u64,
})
}
pub(crate) const PARSE_SLACK: usize = 16;
#[derive(Clone, Debug)]
pub(crate) struct PageRecord {
pub(crate) r: Option<ObjRef>,
pub(crate) dict: Dict,
pub(crate) resources: Dict,
pub(crate) media_box: Option<pdfboss_core::Rect>,
pub(crate) crop_box: Option<pdfboss_core::Rect>,
pub(crate) bleed_box: Option<pdfboss_core::Rect>,
pub(crate) trim_box: Option<pdfboss_core::Rect>,
pub(crate) art_box: Option<pdfboss_core::Rect>,
pub(crate) rotate: Option<i32>,
}
#[derive(Clone, Default)]
struct InheritedAttrs {
resources: Dict,
media_box: Option<pdfboss_core::Rect>,
crop_box: Option<pdfboss_core::Rect>,
rotate: Option<i32>,
}
const MAX_TREE_DEPTH: usize = 256;
#[derive(Clone)]
pub(crate) struct SectionRecord {
pub(crate) kind: XrefKind,
pub(crate) span: Span,
pub(crate) entries: usize,
pub(crate) trailer_dict: Dict,
pub(crate) trailer_span: Span,
}
pub(crate) struct ParsedSection {
pub(crate) record: SectionRecord,
pub(crate) entries: Vec<(u32, XrefEntry)>,
pub(crate) prev: Option<u64>,
pub(crate) xrefstm: Option<u64>,
}
pub(crate) fn parse_section_window(
buf: &[u8],
base: u64,
file_len: u64,
at_eof: bool,
) -> Result<Option<ParsedSection>> {
let mut probe = Lexer::new(buf);
let classic = matches!(probe.peek_token(),
Ok(Token::Keyword(ref k)) if k.as_slice() == b"xref");
if classic {
parse_classic_window(buf, base, file_len, at_eof)
} else {
parse_stream_window(buf, base, at_eof)
}
}
fn parse_classic_window(
buf: &[u8],
base: u64,
file_len: u64,
at_eof: bool,
) -> Result<Option<ParsedSection>> {
fn incomplete<T>(at_eof: bool) -> Result<Option<T>> {
if at_eof {
Err(Error::Core(pdfboss_core::Error::InvalidXref))
} else {
Ok(None)
}
}
let mut lexer = Lexer::new(buf);
match lexer.next_token() {
Ok(Token::Keyword(ref k)) if k.as_slice() == b"xref" => {}
_ => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
}
let mut entries: Vec<(u32, XrefEntry)> = Vec::new();
loop {
lexer.skip_whitespace_and_comments();
let keyword_start = lexer.pos();
let token = match lexer.next_token() {
Ok(t) => t,
Err(_) => return incomplete(at_eof),
};
match token {
Token::Int(start) if start >= 0 => {
let count = match lexer.next_token() {
Ok(Token::Int(c)) if c >= 0 => c as u64,
Ok(Token::Eof) => return incomplete(at_eof),
Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
Err(_) => return incomplete(at_eof),
};
if count > file_len / 11 + 1 {
return Err(Error::Core(pdfboss_core::Error::InvalidXref));
}
for i in 0..count {
let field1 = match lexer.next_token() {
Ok(Token::Int(v)) if v >= 0 => v as u64,
Ok(Token::Eof) => return incomplete(at_eof),
Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
Err(_) => return incomplete(at_eof),
};
let field2 = match lexer.next_token() {
Ok(Token::Int(v)) if v >= 0 => v,
Ok(Token::Eof) => return incomplete(at_eof),
Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
Err(_) => return incomplete(at_eof),
};
let entry = match lexer.next_token() {
Ok(Token::Keyword(ref k)) if k.as_slice() == b"n" => XrefEntry::InFile {
offset: field1,
gen: field2.min(65535) as u16,
},
Ok(Token::Keyword(ref k)) if k.as_slice() == b"f" => XrefEntry::Free,
Ok(Token::Eof) => return incomplete(at_eof),
Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
Err(_) => return incomplete(at_eof),
};
if let Ok(num) = u32::try_from(start as u64 + i) {
entries.push((num, entry));
}
}
if lexer.pos() + PARSE_SLACK > buf.len() && !at_eof {
return Ok(None);
}
}
Token::Keyword(ref k) if k.as_slice() == b"trailer" => {
let mut parser = Parser::at(buf, lexer.pos());
let trailer_dict = match parser.parse_object(&NoResolve) {
Ok(Object::Dict(d)) => d,
Ok(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
Err(_) => return incomplete(at_eof),
};
if parser.pos() + PARSE_SLACK > buf.len() && !at_eof {
return Ok(None); }
let prev = trailer_dict.get_int("Prev").and_then(non_negative);
let xrefstm = trailer_dict.get_int("XRefStm").and_then(non_negative);
let entry_count = entries.len();
return Ok(Some(ParsedSection {
record: SectionRecord {
kind: XrefKind::Table,
span: Span {
start: base,
end: base + keyword_start as u64,
},
entries: entry_count,
trailer_dict,
trailer_span: Span {
start: base + keyword_start as u64,
end: base + parser.pos() as u64,
},
},
entries,
prev,
xrefstm,
}));
}
Token::Eof => return incomplete(at_eof),
_ => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
}
}
}
fn parse_stream_window(buf: &[u8], base: u64, at_eof: bool) -> Result<Option<ParsedSection>> {
let mut parser = Parser::at(buf, 0);
let stream = match parser.parse_indirect(&NoResolve) {
Ok((_, Object::Stream(s))) => s,
Ok(_) => {
if !at_eof && parser.pos() + PARSE_SLACK > buf.len() {
return Ok(None);
}
return Err(Error::Core(pdfboss_core::Error::InvalidXref));
}
Err(_) if !at_eof => return Ok(None),
Err(_) => return Err(Error::Core(pdfboss_core::Error::InvalidXref)),
};
if parser.pos() + PARSE_SLACK > buf.len() && !at_eof {
return Ok(None); }
match stream.dict.get_int("Length") {
Some(declared) if declared >= 0 => {
if stream.data.len() as u64 != declared as u64 && !at_eof {
return Ok(None);
}
}
_ => {
if !at_eof {
return Ok(None);
}
}
}
let decoded = pdfboss_core::filters::decode_stream(&stream, &NoResolve)
.map_err(|_| Error::Core(pdfboss_core::Error::InvalidXref))?;
let dict = stream.dict;
let widths: Vec<usize> = dict
.get_array("W")
.ok_or(Error::Core(pdfboss_core::Error::InvalidXref))?
.iter()
.map(|v| {
v.as_int()
.filter(|&n| (0..=8).contains(&n))
.map(|n| n as usize)
})
.collect::<Option<Vec<_>>>()
.ok_or(Error::Core(pdfboss_core::Error::InvalidXref))?;
let w1 = widths.first().copied().unwrap_or(0);
let w2 = widths.get(1).copied().unwrap_or(0);
let w3 = widths.get(2).copied().unwrap_or(0);
let entry_len = w1 + w2 + w3;
if entry_len == 0 {
return Err(Error::Core(pdfboss_core::Error::InvalidXref));
}
let size = dict.get_int("Size").unwrap_or(0).max(0) as u64;
let subsections: Vec<(u64, u64)> = match dict.get_array("Index") {
Some(index) => index
.chunks(2)
.filter_map(|pair| {
let start = pair.first()?.as_int()?;
let count = pair.get(1)?.as_int()?;
(start >= 0 && count >= 0).then_some((start as u64, count as u64))
})
.collect(),
None => vec![(0, size)],
};
let mut entries: Vec<(u32, XrefEntry)> = Vec::new();
let mut pos = 0usize;
'subsections: for (start, count) in subsections {
for i in 0..count {
if pos + entry_len > decoded.len() {
break 'subsections; }
let kind = if w1 == 0 {
1
} else {
read_be(&decoded[pos..pos + w1])
};
let field2 = read_be(&decoded[pos + w1..pos + w1 + w2]);
let field3 = read_be(&decoded[pos + w1 + w2..pos + entry_len]);
pos += entry_len;
let entry = match kind {
1 => XrefEntry::InFile {
offset: field2,
gen: field3.min(65535) as u16,
},
2 => match (u32::try_from(field2), u32::try_from(field3)) {
(Ok(stream_num), Ok(index)) => XrefEntry::InStream { stream_num, index },
_ => XrefEntry::Free,
},
_ => XrefEntry::Free,
};
if let Ok(num) = u32::try_from(start + i) {
entries.push((num, entry));
}
}
}
let prev = dict.get_int("Prev").and_then(non_negative);
let span = Span {
start: base,
end: base + parser.pos() as u64,
};
let entry_count = entries.len();
Ok(Some(ParsedSection {
record: SectionRecord {
kind: XrefKind::Stream,
span,
entries: entry_count,
trailer_dict: dict,
trailer_span: span,
},
entries,
prev,
xrefstm: None,
}))
}
fn read_be(bytes: &[u8]) -> u64 {
bytes.iter().fold(0, |acc, &b| (acc << 8) | u64::from(b))
}
fn non_negative(value: i64) -> Option<u64> {
u64::try_from(value).ok()
}
pub(crate) struct XrefIndex {
pub(crate) entries: HashMap<u32, XrefEntry>,
pub(crate) trailer: Dict,
pub(crate) trailer_span: Span,
}
#[derive(Clone)]
pub struct AsyncDocument {
pub(crate) inner: Arc<DocumentInner>,
}
pub(crate) struct DocumentInner {
pub(crate) backend: Arc<dyn Backend>,
pub(crate) file_len: u64,
pub(crate) version: (u8, u8),
pub(crate) header_span: Option<Span>,
pub(crate) xref: XrefIndex,
pub(crate) sections: Vec<SectionRecord>,
pub(crate) startxref: StartXrefRecord,
pub(crate) eof_span: Option<Span>,
pub(crate) objects: std::sync::Mutex<HashMap<(u32, u16), Arc<Object>>>,
pub(crate) objstms: tokio::sync::Mutex<HashMap<u32, Arc<ObjStmCache>>>,
pub(crate) pages: std::sync::OnceLock<Vec<PageRecord>>,
pub(crate) decryptor: std::sync::OnceLock<pdfboss_core::Decryptor>,
}
impl AsyncDocument {
pub async fn open(path: impl AsRef<Path>) -> Result<AsyncDocument> {
AsyncDocument::open_with_password(path, "").await
}
pub async fn open_with_password(
path: impl AsRef<Path>,
password: &str,
) -> Result<AsyncDocument> {
let backend = FileBackend::open(path).await.map_err(Error::from)?;
AsyncDocument::from_arc(Arc::new(CachedBackend::new(backend)), password).await
}
pub async fn from_bytes(bytes: impl Into<Bytes>) -> Result<AsyncDocument> {
AsyncDocument::from_bytes_with_password(bytes, "").await
}
pub async fn from_bytes_with_password(
bytes: impl Into<Bytes>,
password: &str,
) -> Result<AsyncDocument> {
AsyncDocument::from_arc(Arc::new(MemBackend::from(bytes.into())), password).await
}
pub async fn with_backend(backend: impl Backend) -> Result<AsyncDocument> {
AsyncDocument::from_arc(Arc::new(backend), "").await
}
#[cfg(feature = "http")]
pub async fn open_url(url: impl reqwest::IntoUrl) -> Result<AsyncDocument> {
AsyncDocument::open_url_with_password(url, "").await
}
#[cfg(feature = "http")]
pub async fn open_url_with_password(
url: impl reqwest::IntoUrl,
password: &str,
) -> Result<AsyncDocument> {
let backend = crate::backend::HttpBackend::new(url).await?;
AsyncDocument::from_arc(Arc::new(CachedBackend::new(backend)), password).await
}
async fn from_arc(backend: Arc<dyn Backend>, password: &str) -> Result<AsyncDocument> {
let file_len = backend.len().await.map_err(Error::from)?;
let fetcher = Fetcher {
backend: Arc::clone(&backend),
len: file_len,
};
let head = fetcher.window(0, 1024).await?;
let version = parse_version(&head);
let header_span = header_span_in(&head);
let (startxref, eof_span) = find_tail(&fetcher).await?;
let (xref, sections) = load_xref_chain(&fetcher, startxref.offset).await?;
let encrypted = xref.trailer.get("Encrypt").is_some_and(|o| !o.is_null());
let inner = DocumentInner {
backend,
file_len,
version,
header_span,
xref,
sections,
startxref,
eof_span,
objects: std::sync::Mutex::new(HashMap::new()),
objstms: tokio::sync::Mutex::new(HashMap::new()),
pages: std::sync::OnceLock::new(),
decryptor: std::sync::OnceLock::new(),
};
let doc = AsyncDocument {
inner: Arc::new(inner),
};
if encrypted {
doc.setup_decryption(password).await?;
}
let pages = doc.flatten_pages().await;
doc.inner
.pages
.set(pages)
.expect("page index is set exactly once at open");
Ok(doc)
}
async fn setup_decryption(&self, password: &str) -> Result<()> {
let enc_obj = self
.inner
.xref
.trailer
.get("Encrypt")
.cloned()
.unwrap_or(Object::Null);
let enc = self.resolve(&enc_obj).await?;
let enc_dict = enc
.as_dict()
.ok_or(Error::Core(pdfboss_core::Error::Encrypted))?;
let id0: Vec<u8> = self
.inner
.xref
.trailer
.get("ID")
.and_then(Object::as_array)
.and_then(<[Object]>::first)
.and_then(Object::as_str_bytes)
.unwrap_or(&[])
.to_vec();
match pdfboss_core::Decryptor::from_standard_with_password_str(enc_dict, &id0, password) {
Some(dec) => {
self.inner
.decryptor
.set(dec)
.map_err(|_| Error::Core(pdfboss_core::Error::Encrypted))?;
self.inner
.objects
.lock()
.expect("object cache mutex")
.clear();
self.inner.objstms.lock().await.clear();
Ok(())
}
None => Err(Error::Core(pdfboss_core::Error::Encrypted)),
}
}
pub fn version(&self) -> (u8, u8) {
self.inner.version
}
pub(crate) fn fetcher(&self) -> Fetcher {
Fetcher {
backend: Arc::clone(&self.inner.backend),
len: self.inner.file_len,
}
}
}
impl AsyncObjectSource for AsyncDocument {
fn get(&self, r: ObjRef) -> BoxFuture<'_, pdfboss_core::Result<Object>> {
Box::pin(async move { self.get_object(r).await.map_err(Into::into) })
}
fn stream_data<'a>(&'a self, s: &'a Stream) -> BoxFuture<'a, pdfboss_core::Result<Vec<u8>>> {
Box::pin(async move { self.decode_stream(s).await.map_err(Into::into) })
}
fn resolve<'a>(&'a self, o: &'a Object) -> BoxFuture<'a, pdfboss_core::Result<Object>> {
Box::pin(async move { self.resolve(o).await.map_err(Into::into) })
}
}
const OBJECT_WINDOW: usize = 2048;
const MAX_RESOLVE_DEPTH: usize = 32;
struct LengthProbe {
known: Option<(ObjRef, i64)>,
missing: std::cell::Cell<Option<ObjRef>>,
}
impl LengthProbe {
fn new(known: Option<(ObjRef, i64)>) -> LengthProbe {
LengthProbe {
known,
missing: std::cell::Cell::new(None),
}
}
fn missing(&self) -> Option<ObjRef> {
self.missing.get()
}
}
impl Resolve for LengthProbe {
fn resolve_ref(&self, r: ObjRef) -> Option<Object> {
match self.known {
Some((known_ref, value)) if known_ref == r => Some(Object::Int(value)),
_ => {
if self.missing.get().is_none() {
self.missing.set(Some(r));
}
None
}
}
}
}
fn stream_dishonors_length(object: &Object, known_length: Option<(ObjRef, i64)>) -> bool {
let Some(stream) = object.as_stream() else {
return false;
};
let declared = match stream.dict.get("Length") {
Some(Object::Int(n)) => Some(*n),
Some(Object::Ref(r)) => {
known_length.and_then(|(known_ref, value)| (known_ref == *r).then_some(value))
}
_ => None,
};
match declared {
Some(length) if length >= 0 => stream.data.len() as u64 != length as u64,
_ => false,
}
}
fn object_type_name(o: &Object) -> &'static str {
match o {
Object::Null => "null",
Object::Bool(_) => "boolean",
Object::Int(_) => "integer",
Object::Real(_) => "real",
Object::String(_) => "string",
Object::Name(_) => "name",
Object::Array(_) => "array",
Object::Dict(_) => "dictionary",
Object::Stream(_) => "stream",
Object::Ref(_) => "reference",
}
}
pub(crate) struct ObjStmCache {
pub(crate) container: ObjRef,
pub(crate) container_span: Span,
first: usize,
data: Vec<u8>,
pub(crate) members: Vec<(u32, usize)>,
}
impl ObjStmCache {
pub(crate) fn object(&self, index: u32) -> Result<Object> {
let start = self.member_start(index)?;
Parser::at(&self.data, start)
.parse_object(&NoResolve)
.map_err(Error::Core)
}
pub(crate) fn member_span(&self, index: u32) -> Result<Span> {
let start = self.member_start(index)?;
let mut parser = Parser::at(&self.data, start);
parser.parse_object(&NoResolve).map_err(Error::Core)?;
Ok(Span {
start: start as u64,
end: parser.pos() as u64,
})
}
fn member_start(&self, index: u32) -> Result<usize> {
let offset = self
.members
.get(index as usize)
.map(|entry| entry.1)
.ok_or_else(|| {
Error::Core(pdfboss_core::Error::Other(format!(
"object stream index {index} out of range (N = {})",
self.members.len()
)))
})?;
self.first
.checked_add(offset)
.filter(|&pos| pos <= self.data.len())
.ok_or_else(|| {
Error::Core(pdfboss_core::Error::Other(format!(
"object stream offset {offset} lies outside the stream"
)))
})
}
}
fn parse_objstm_header(data: &[u8], n: usize) -> Result<Vec<(u32, usize)>> {
let mut lexer = Lexer::new(data);
let mut members = Vec::with_capacity(n);
for _ in 0..n {
let num = expect_header_int(&mut lexer)?;
let offset = expect_header_int(&mut lexer)?;
members.push((u32::try_from(num).unwrap_or(u32::MAX), offset));
}
Ok(members)
}
fn expect_header_int(lexer: &mut Lexer) -> Result<usize> {
match lexer.next_token().map_err(Error::Core)? {
Token::Int(v) if v >= 0 => Ok(v as usize),
_ => Err(Error::Core(pdfboss_core::Error::Syntax {
offset: lexer.pos(),
msg: "malformed object stream header".to_string(),
})),
}
}
struct MapResolve(HashMap<ObjRef, Object>);
impl Resolve for MapResolve {
fn resolve_ref(&self, r: ObjRef) -> Option<Object> {
self.0.get(&r).cloned()
}
}
impl AsyncDocument {
pub async fn get_object(&self, r: ObjRef) -> Result<Object> {
let mut chain = Vec::new();
self.fetch_object_cached(r, &mut chain).await
}
pub async fn resolve(&self, o: &Object) -> Result<Object> {
let mut chain = Vec::new();
self.resolve_with_chain(o, &mut chain).await
}
pub(crate) fn fetch_object_cached<'a>(
&'a self,
r: ObjRef,
chain: &'a mut Vec<u32>,
) -> BoxFuture<'a, Result<Object>> {
Box::pin(async move {
if let Some(cached) = self
.inner
.objects
.lock()
.expect("object cache mutex")
.get(&(r.num, r.gen))
{
return Ok((**cached).clone());
}
if chain.contains(&r.num) {
return Err(Error::Core(pdfboss_core::Error::CircularReference(r.num)));
}
chain.push(r.num);
let outcome = self.load_object(r, chain).await;
chain.pop();
let object = outcome?;
self.inner
.objects
.lock()
.expect("object cache mutex")
.insert((r.num, r.gen), Arc::new(object.clone()));
Ok(object)
})
}
async fn load_object(&self, r: ObjRef, chain: &mut Vec<u32>) -> Result<Object> {
match self.inner.xref.entries.get(&r.num).copied() {
None | Some(XrefEntry::Free) => Err(Error::Core(pdfboss_core::Error::ObjectNotFound(
r.num, r.gen,
))),
Some(XrefEntry::InFile { offset, .. }) => {
let parsed = self.parse_in_file(offset, chain).await?;
Ok(parsed.1)
}
Some(XrefEntry::InStream { stream_num, index }) => {
let cache = self.objstm_cache_with_chain(stream_num, chain).await?;
cache.object(index)
}
}
}
pub(crate) async fn parse_in_file(
&self,
offset: u64,
chain: &mut Vec<u32>,
) -> Result<(Span, Object)> {
if offset >= self.inner.file_len {
return Err(Error::Core(pdfboss_core::Error::Other(format!(
"object offset {offset} lies outside the file"
))));
}
let fetcher = self.fetcher();
let mut window = OBJECT_WINDOW;
let mut known_length: Option<(ObjRef, i64)> = None;
loop {
let buf = fetcher.window(offset, window).await?;
let at_eof = offset + buf.len() as u64 >= self.inner.file_len;
let probe = LengthProbe::new(known_length);
let mut parser = Parser::at(&buf, 0);
match parser.parse_indirect(&probe) {
Ok((parsed_ref, mut object)) => {
let end = parser.pos();
if end + PARSE_SLACK <= buf.len() || at_eof {
if let Some(missing) = probe.missing() {
if let Ok(length_object) =
self.fetch_object_cached(missing, chain).await
{
if let Some(value) = length_object.as_int() {
known_length = Some((missing, value));
continue;
}
}
}
if at_eof || !stream_dishonors_length(&object, known_length) {
if let Some(dec) = self.inner.decryptor.get() {
dec.decrypt_object(&mut object, parsed_ref.num, parsed_ref.gen);
}
return Ok((
Span {
start: offset,
end: offset + end as u64,
},
object,
));
}
}
}
Err(parse_error) => {
if at_eof {
return Err(Error::Core(parse_error));
}
}
}
if window as u64 >= MAX_GROWTH_WINDOW {
return Err(Error::Core(pdfboss_core::Error::Other(format!(
"invalid or unrecoverable cross-reference data: object at offset {offset} \
exceeded the {MAX_GROWTH_WINDOW}-byte parse window without completing"
))));
}
window = window.saturating_mul(2);
}
}
pub(crate) async fn objstm_cache(&self, stream_num: u32) -> Result<Arc<ObjStmCache>> {
let mut chain = Vec::new();
self.objstm_cache_with_chain(stream_num, &mut chain).await
}
async fn objstm_cache_with_chain(
&self,
stream_num: u32,
chain: &mut Vec<u32>,
) -> Result<Arc<ObjStmCache>> {
if chain.contains(&stream_num) {
return Err(Error::Core(pdfboss_core::Error::CircularReference(
stream_num,
)));
}
if let Some(hit) = self.inner.objstms.lock().await.get(&stream_num) {
return Ok(Arc::clone(hit));
}
let offset = match self.inner.xref.entries.get(&stream_num).copied() {
Some(XrefEntry::InFile { offset, .. }) => offset,
Some(XrefEntry::InStream { .. }) | Some(XrefEntry::Free) | None => {
return Err(Error::Core(pdfboss_core::Error::ObjectNotFound(
stream_num, 0,
)))
}
};
chain.push(stream_num);
let outcome = self.build_objstm_cache(stream_num, offset, chain).await;
chain.pop();
let entry = outcome?;
let mut cache = self.inner.objstms.lock().await;
let stored = cache
.entry(stream_num)
.or_insert_with(|| Arc::clone(&entry));
Ok(Arc::clone(stored))
}
async fn build_objstm_cache(
&self,
stream_num: u32,
offset: u64,
chain: &mut Vec<u32>,
) -> Result<Arc<ObjStmCache>> {
let (container_span, object) = self.parse_in_file(offset, chain).await?;
let stream = object
.as_stream()
.ok_or(Error::Core(pdfboss_core::Error::TypeMismatch {
expected: "stream",
found: object_type_name(&object),
}))?;
let n = self
.resolve_with_chain(stream.dict.get("N").unwrap_or(&Object::Null), chain)
.await?
.as_int()
.and_then(|v| usize::try_from(v).ok())
.ok_or(Error::Core(pdfboss_core::Error::MissingKey("N")))?;
let first = self
.resolve_with_chain(stream.dict.get("First").unwrap_or(&Object::Null), chain)
.await?
.as_int()
.and_then(|v| usize::try_from(v).ok())
.ok_or(Error::Core(pdfboss_core::Error::MissingKey("First")))?;
let data = self.decode_stream_with_chain(stream, chain).await?;
let members = parse_objstm_header(&data, n)?;
Ok(Arc::new(ObjStmCache {
container: ObjRef {
num: stream_num,
gen: 0,
},
container_span,
first,
data,
members,
}))
}
pub(crate) async fn resolve_with_chain(
&self,
o: &Object,
chain: &mut Vec<u32>,
) -> Result<Object> {
let mut current = o.clone();
let mut last_num = 0;
for _ in 0..MAX_RESOLVE_DEPTH {
match current {
Object::Ref(r) => {
last_num = r.num;
current = match self.fetch_object_cached(r, chain).await {
Ok(object) => object,
Err(Error::Core(pdfboss_core::Error::CircularReference(n))) => {
return Err(Error::Core(pdfboss_core::Error::CircularReference(n)))
}
Err(_) => return Ok(Object::Null),
};
}
other => return Ok(other),
}
}
Err(Error::Core(pdfboss_core::Error::CircularReference(
last_num,
)))
}
pub(crate) async fn decode_stream_with_chain(
&self,
s: &Stream,
chain: &mut Vec<u32>,
) -> Result<Vec<u8>> {
let resolver = self.prefetch_filter_refs(&s.dict, chain).await;
pdfboss_core::filters::decode_stream(s, &resolver).map_err(Error::Core)
}
async fn prefetch_filter_refs(&self, dict: &Dict, chain: &mut Vec<u32>) -> MapResolve {
const FILTER_KEYS: [&str; 5] = ["Length", "Filter", "DecodeParms", "DP", "F"];
let mut map: HashMap<ObjRef, Object> = HashMap::new();
let mut frontier: Vec<Object> = FILTER_KEYS
.iter()
.filter_map(|key| dict.get(key).cloned())
.collect();
for _ in 0..MAX_RESOLVE_DEPTH {
let mut next = Vec::new();
for value in frontier.drain(..) {
match value {
Object::Ref(r) => {
if let std::collections::hash_map::Entry::Vacant(entry) = map.entry(r) {
let resolved = match self.fetch_object_cached(r, chain).await {
Ok(object) => object,
Err(_) => Object::Null,
};
next.push(resolved.clone());
entry.insert(resolved);
}
}
Object::Array(items) => next.extend(items),
Object::Dict(d) => next.extend(d.iter().map(|(_, v)| v.clone())),
_ => {}
}
}
if next.is_empty() {
break;
}
frontier = next;
}
MapResolve(map)
}
pub async fn decode_stream(&self, s: &Stream) -> Result<Vec<u8>> {
let mut chain = Vec::new();
self.decode_stream_with_chain(s, &mut chain).await
}
pub async fn read_span(&self, span: Span) -> Result<Vec<u8>> {
let start = span.start.min(self.inner.file_len);
let end = span.end.min(self.inner.file_len);
if start >= end {
return Ok(Vec::new());
}
self.fetcher().read_range(start, end).await
}
pub fn file_len(&self) -> u64 {
self.inner.file_len
}
pub async fn metadata(&self) -> Result<Metadata> {
let mut meta = Metadata::default();
let Some(info) = self.inner.xref.trailer.get("Info") else {
return Ok(meta);
};
let Ok(info) = self.resolve(info).await else {
return Ok(meta);
};
let Some(dict) = info.as_dict() else {
return Ok(meta);
};
meta.title = self.meta_string(dict, "Title").await;
meta.author = self.meta_string(dict, "Author").await;
meta.subject = self.meta_string(dict, "Subject").await;
meta.keywords = self.meta_string(dict, "Keywords").await;
meta.creator = self.meta_string(dict, "Creator").await;
meta.producer = self.meta_string(dict, "Producer").await;
meta.creation_date = self.meta_string(dict, "CreationDate").await;
meta.mod_date = self.meta_string(dict, "ModDate").await;
Ok(meta)
}
async fn meta_string(&self, dict: &Dict, key: &str) -> Option<String> {
let value = self.resolve(dict.get(key)?).await.ok()?;
Some(decode_text_string(value.as_str_bytes()?))
}
pub async fn oc_state(&self) -> Option<pdfboss_core::OcState> {
pdfboss_core::OcState::load_with(self, &self.inner.xref.trailer).await
}
pub fn page_count(&self) -> usize {
self.inner.pages.get().map_or(0, Vec::len)
}
pub fn page(&self, index: usize) -> Result<Page> {
let Some(record) = self.page_record(index) else {
return Err(Error::Core(pdfboss_core::Error::PageNotFound(
index,
self.page_count(),
)));
};
Ok(Page::from_tree_attrs(
index,
Some(record.resources),
record.media_box,
record.crop_box,
record.bleed_box,
record.trim_box,
record.art_box,
record.rotate,
record.dict,
record.r,
))
}
pub(crate) fn page_record(&self, index: usize) -> Option<PageRecord> {
self.inner
.pages
.get()
.and_then(|pages| pages.get(index))
.cloned()
}
async fn flatten_pages(&self) -> Vec<PageRecord> {
let mut chain = Vec::new();
let mut pages = Vec::new();
let Some(root) = self.inner.xref.trailer.get("Root") else {
return pages;
};
let Ok(catalog) = self.resolve_with_chain(root, &mut chain).await else {
return pages;
};
let Some(tree_root) = catalog.as_dict().and_then(|d| d.get("Pages")).cloned() else {
return pages;
};
let mut visited: HashSet<ObjRef> = HashSet::new();
let mut stack: Vec<(Object, InheritedAttrs, usize)> =
vec![(tree_root, InheritedAttrs::default(), 0)];
while let Some((node, mut inherited, depth)) = stack.pop() {
if depth > MAX_TREE_DEPTH {
continue;
}
let node_ref = node.as_ref();
if let Some(r) = node_ref {
if !visited.insert(r) {
continue; }
}
let Ok(resolved) = self.resolve_with_chain(&node, &mut chain).await else {
continue;
};
let Some(dict) = resolved.as_dict() else {
continue;
};
if let Some(value) = dict.get("Resources") {
if let Ok(res) = self.resolve_with_chain(value, &mut chain).await {
if let Some(res) = res.as_dict() {
inherited.resources = res.clone();
}
}
}
if let Some(mb) = self.rect_value(dict, "MediaBox", &mut chain).await {
inherited.media_box = Some(mb);
}
if let Some(cb) = self.rect_value(dict, "CropBox", &mut chain).await {
inherited.crop_box = Some(cb);
}
if let Some(rot) = self.int_value(dict, "Rotate", &mut chain).await {
inherited.rotate = Some(rot);
}
let is_page = dict.get_name("Type").is_some_and(|n| n.0 == "Page");
let kids = if is_page {
None
} else {
self.array_value(dict, "Kids", &mut chain).await
};
match kids {
Some(kids) => {
for kid in kids.iter().rev() {
stack.push((kid.clone(), inherited.clone(), depth + 1));
}
}
None => {
let bleed_box = self.rect_value(dict, "BleedBox", &mut chain).await;
let trim_box = self.rect_value(dict, "TrimBox", &mut chain).await;
let art_box = self.rect_value(dict, "ArtBox", &mut chain).await;
pages.push(PageRecord {
r: node_ref,
dict: dict.clone(),
resources: inherited.resources.clone(),
media_box: inherited.media_box,
crop_box: inherited.crop_box,
bleed_box,
trim_box,
art_box,
rotate: inherited.rotate,
});
}
}
}
pages
}
async fn rect_value(
&self,
dict: &Dict,
key: &str,
chain: &mut Vec<u32>,
) -> Option<pdfboss_core::Rect> {
let items = self.array_value(dict, key, chain).await?;
if items.len() != 4 {
return None;
}
let mut coords = [0.0f32; 4];
for (slot, item) in coords.iter_mut().zip(&items) {
let n = self.resolve_with_chain(item, chain).await.ok()?.as_f64()?;
if !n.is_finite() {
return None;
}
*slot = n as f32;
}
Some(pdfboss_core::Rect::new(coords[0], coords[1], coords[2], coords[3]).normalize())
}
async fn int_value(&self, dict: &Dict, key: &str, chain: &mut Vec<u32>) -> Option<i32> {
let v = self
.resolve_with_chain(dict.get(key)?, chain)
.await
.ok()?
.as_f64()?;
if v.is_finite() {
Some(v as i32)
} else {
None
}
}
async fn array_value(
&self,
dict: &Dict,
key: &str,
chain: &mut Vec<u32>,
) -> Option<Vec<Object>> {
match self.resolve_with_chain(dict.get(key)?, chain).await.ok()? {
Object::Array(items) => Some(items),
_ => None,
}
}
pub fn elements(&self, opts: ElementOpts) -> crate::stream::ElementStream {
crate::stream::element_stream(self, opts)
}
pub(crate) async fn physical_object(&self, r: ObjRef, offset: u64) -> Result<(Span, Object)> {
let mut chain = vec![r.num];
let (span, object) = self.parse_in_file(offset, &mut chain).await?;
self.inner
.objects
.lock()
.expect("object cache mutex")
.insert((r.num, r.gen), Arc::new(object.clone()));
Ok((span, object))
}
pub(crate) fn header_span(&self) -> Option<Span> {
self.inner.header_span
}
pub(crate) fn xref_entries(&self) -> Vec<(u32, XrefEntry)> {
self.inner
.xref
.entries
.iter()
.map(|(&num, &entry)| (num, entry))
.collect()
}
pub(crate) fn sections(&self) -> &[SectionRecord] {
&self.inner.sections
}
pub(crate) fn merged_trailer(&self) -> (Dict, Span) {
(
self.inner.xref.trailer.clone(),
self.inner.xref.trailer_span,
)
}
pub(crate) fn startxref_record(&self) -> (u64, Span) {
(self.inner.startxref.offset, self.inner.startxref.span)
}
pub(crate) fn eof_span(&self) -> Option<Span> {
self.inner.eof_span
}
}
const SECTION_WINDOW: usize = 4096;
async fn parse_section_at(fetcher: &Fetcher, offset: u64) -> Result<ParsedSection> {
let mut window = SECTION_WINDOW;
loop {
let buf = fetcher.window(offset, window).await?;
let at_eof = offset + buf.len() as u64 >= fetcher.len;
if let Some(parsed) = parse_section_window(&buf, offset, fetcher.len, at_eof)? {
return Ok(parsed);
}
if window as u64 >= MAX_GROWTH_WINDOW {
return Err(Error::Core(pdfboss_core::Error::Other(format!(
"invalid or unrecoverable cross-reference data: section at offset {offset} \
exceeded the {MAX_GROWTH_WINDOW}-byte parse window without completing"
))));
}
window = window.saturating_mul(2);
}
}
pub(crate) async fn load_xref_chain(
fetcher: &Fetcher,
start: u64,
) -> Result<(XrefIndex, Vec<SectionRecord>)> {
let mut entries: HashMap<u32, XrefEntry> = HashMap::new();
let mut trailer = Dict::new();
let mut trailer_span: Option<Span> = None;
let mut sections: Vec<SectionRecord> = Vec::new();
let mut visited: HashSet<u64> = HashSet::new();
let mut next = Some(start);
while let Some(offset) = next {
if !visited.insert(offset) {
break;
}
let parsed = parse_section_at(fetcher, offset).await?;
if trailer_span.is_none() {
trailer_span = Some(parsed.record.trailer_span);
}
let mut hybrid_record = None;
if let Some(hybrid_offset) = parsed.xrefstm.filter(|&v| v < fetcher.len) {
if visited.insert(hybrid_offset) {
if let Ok(hybrid) = parse_section_at(fetcher, hybrid_offset).await {
merge_section(&mut entries, &mut trailer, &hybrid);
hybrid_record = Some(hybrid.record);
}
}
}
next = parsed.prev.filter(|&v| v < fetcher.len);
merge_section(&mut entries, &mut trailer, &parsed);
sections.push(parsed.record);
if let Some(record) = hybrid_record {
sections.push(record);
}
}
if entries.is_empty() {
return Err(Error::Core(pdfboss_core::Error::InvalidXref));
}
let trailer_span = trailer_span.expect("set on the first parsed section");
Ok((
XrefIndex {
entries,
trailer,
trailer_span,
},
sections,
))
}
fn merge_section(
entries: &mut HashMap<u32, XrefEntry>,
trailer: &mut Dict,
parsed: &ParsedSection,
) {
for (num, entry) in &parsed.entries {
entries.entry(*num).or_insert(*entry);
}
for (key, value) in parsed.record.trailer_dict.iter() {
if trailer.get(&key.0).is_none() {
trailer.insert(key.clone(), value.clone());
}
}
}
#[allow(dead_code)]
fn assert_document_is_shareable()
where
AsyncDocument: Send + Sync + Clone,
{
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::MemBackend;
use pdfboss_testkit::{multi_page_doc, simple_doc};
fn fetcher_for(data: Vec<u8>) -> Fetcher {
let len = data.len() as u64;
Fetcher {
backend: std::sync::Arc::new(MemBackend::from(data)),
len,
}
}
fn pos_of(data: &[u8], needle: &[u8]) -> usize {
data.windows(needle.len())
.position(|w| w == needle)
.expect("needle present")
}
#[tokio::test]
async fn read_range_returns_exact_bytes_and_detects_truncation() {
let fetcher = fetcher_for(b"0123456789".to_vec());
assert_eq!(fetcher.read_range(2, 6).await.unwrap(), b"2345");
assert_eq!(fetcher.window(8, 100).await.unwrap(), b"89");
assert!(fetcher.window(10, 100).await.unwrap().is_empty());
let lying = Fetcher {
backend: std::sync::Arc::new(MemBackend::from(b"0123456789".to_vec())),
len: 20,
};
match lying.read_range(5, 15).await {
Err(crate::Error::TruncatedRead {
offset,
wanted,
got,
}) => {
assert_eq!(offset, 5);
assert_eq!(wanted, 10);
assert_eq!(got, 5);
}
other => panic!("expected TruncatedRead, got {other:?}"),
}
}
#[tokio::test]
async fn tail_scan_finds_startxref_and_eof() {
let data = simple_doc("tail scan");
let xref_pos = pos_of(&data, b"xref\n0 ") as u64;
let startxref_pos = pos_of(&data, b"startxref") as u64;
let eof_pos = pos_of(&data, b"%%EOF") as u64;
let fetcher = fetcher_for(data);
let (record, eof) = find_tail(&fetcher).await.unwrap();
assert_eq!(record.offset, xref_pos);
assert_eq!(record.span.start, startxref_pos);
assert!(record.span.end > startxref_pos + b"startxref".len() as u64);
assert_eq!(
eof,
Some(Span {
start: eof_pos,
end: eof_pos + 5
})
);
}
#[tokio::test]
async fn tail_scan_grows_past_trailing_padding() {
let mut data = simple_doc("padded");
let xref_pos = pos_of(&data, b"xref\n0 ") as u64;
data.extend_from_slice(&vec![b' '; 8192]);
let fetcher = fetcher_for(data);
let (record, eof) = find_tail(&fetcher).await.unwrap();
assert_eq!(record.offset, xref_pos);
assert!(eof.is_some());
}
#[tokio::test]
async fn tail_scan_without_startxref_is_invalid_xref() {
let fetcher = fetcher_for(b"not a pdf at all".to_vec());
assert!(matches!(
find_tail(&fetcher).await,
Err(crate::Error::Core(pdfboss_core::Error::InvalidXref))
));
}
#[test]
fn version_parse_matches_header_and_defaults() {
assert_eq!(parse_version(b"%PDF-1.7\nrest"), (1, 7));
assert_eq!(parse_version(b"junk\n%PDF-2.0\n"), (2, 0));
assert_eq!(parse_version(b"%QQQ-1.7"), (1, 4));
assert_eq!(parse_version(b""), (1, 4));
assert_eq!(parse_version(b"%PDF-1."), (1, 4));
}
#[test]
fn header_span_covers_the_version_run() {
assert_eq!(
header_span_in(b"%PDF-1.7\nrest"),
Some(Span { start: 0, end: 8 })
);
assert_eq!(
header_span_in(b"junk\n%PDF-2.0\n"),
Some(Span { start: 5, end: 13 })
);
assert_eq!(header_span_in(b"%QQQ-1.7"), None);
assert_eq!(header_span_in(b""), None);
}
use pdfboss_core::xref::XrefEntry;
fn classic_section(data: &[u8]) -> (Vec<u8>, u64) {
let off = pos_of(data, b"xref\n0 ");
(data[off..].to_vec(), off as u64)
}
#[test]
fn classic_section_window_parses_entries_and_trailer() {
let data = pdfboss_testkit::simple_doc("sections");
let (buf, base) = classic_section(&data);
let file_len = data.len() as u64;
let parsed = parse_section_window(&buf, base, file_len, true)
.unwrap()
.expect("complete section parses");
assert_eq!(parsed.record.kind, pdfboss_core::elements::XrefKind::Table);
assert_eq!(parsed.record.entries, 6); assert_eq!(parsed.entries.len(), 6);
assert!(matches!(
parsed.entries.iter().find(|(num, _)| *num == 0),
Some((0, XrefEntry::Free))
));
let obj1_off = pos_of(&data, b"1 0 obj") as u64;
assert!(parsed.entries.iter().any(|(num, entry)| *num == 1
&& matches!(entry, XrefEntry::InFile { offset, gen: 0 } if *offset == obj1_off)));
assert_eq!(
parsed.record.trailer_dict.get_ref("Root").map(|r| r.num),
Some(1)
);
assert_eq!(parsed.prev, None);
assert_eq!(parsed.xrefstm, None);
assert_eq!(parsed.record.span.start, base);
let trailer_off = pos_of(&data, b"trailer") as u64;
assert_eq!(parsed.record.span.end, trailer_off);
assert_eq!(parsed.record.trailer_span.start, trailer_off);
let dict_end = pos_of(&data, b"startxref") as u64;
assert!(parsed.record.trailer_span.end > trailer_off);
assert!(parsed.record.trailer_span.end <= dict_end);
}
#[test]
fn truncated_classic_section_asks_for_more_bytes() {
let data = pdfboss_testkit::simple_doc("cut short");
let (buf, base) = classic_section(&data);
let file_len = data.len() as u64;
let cut = &buf[..40];
assert!(parse_section_window(cut, base, file_len, false)
.unwrap()
.is_none());
assert!(parse_section_window(cut, base, base + 40, true).is_err());
}
#[test]
fn xref_stream_section_window_parses_entries() {
let (dict, payload) = pdfboss_testkit::objstm_payload(&[
(1, "<< /Type /Catalog /Pages 2 0 R >>"),
(2, "<< /Type /Pages /Kids [] /Count 0 >>"),
]);
let mut b = pdfboss_testkit::PdfBuilder::new();
b.stream(6, &dict, &payload);
let data = b.build_xref_stream(1);
let off = pos_of(&data, b"7 0 obj") as u64; let buf = data[off as usize..].to_vec();
let parsed = parse_section_window(&buf, off, data.len() as u64, true)
.unwrap()
.expect("complete stream section parses");
assert_eq!(parsed.record.kind, pdfboss_core::elements::XrefKind::Stream);
assert_eq!(parsed.record.span.start, off);
assert_eq!(parsed.record.trailer_span, parsed.record.span);
assert!(parsed.entries.iter().any(|(num, entry)| *num == 1
&& matches!(
entry,
XrefEntry::InStream {
stream_num: 6,
index: 0
}
)));
assert!(parsed
.entries
.iter()
.any(|(num, entry)| *num == 6 && matches!(entry, XrefEntry::InFile { .. })));
assert_eq!(
parsed
.record
.trailer_dict
.get_name("Type")
.map(|n| n.0.as_str()),
Some("XRef")
);
}
#[test]
fn implausible_subsection_count_is_a_hard_error() {
let buf = b"xref\n0 999999999\n".to_vec();
assert!(parse_section_window(&buf, 0, 4096, false).is_err());
}
#[test]
fn truncated_stream_section_asks_for_more_bytes() {
let mid_dict = b"7 0 obj\n<< /Type /XRef /Length 10 ".to_vec();
assert!(parse_section_window(&mid_dict, 0, 10_000, false)
.unwrap()
.is_none());
assert!(parse_section_window(&mid_dict, 0, mid_dict.len() as u64, true).is_err());
let (dict, payload) = pdfboss_testkit::objstm_payload(&[
(1, "<< /Type /Catalog /Pages 2 0 R >>"),
(2, "<< /Type /Pages /Kids [] /Count 0 >>"),
]);
let mut b = pdfboss_testkit::PdfBuilder::new();
b.stream(6, &dict, &payload);
let data = b.build_xref_stream(1);
let off = pos_of(&data, b"7 0 obj");
let buf = data[off..].to_vec();
let base = off as u64;
let file_len = data.len() as u64;
let stream_kw = find_bytes(&buf, b"stream\n").expect("stream keyword present");
let cut = &buf[..stream_kw + b"stream\n".len() + 4];
assert!(parse_section_window(cut, base, file_len, false)
.unwrap()
.is_none());
}
use pdfboss_core::xref::load_xref;
async fn assert_xref_parity(data: Vec<u8>) {
let sync_xref = load_xref(&data).unwrap();
let size = sync_xref.trailer.get_int("Size").unwrap_or(64).max(1) as u32;
let doc = AsyncDocument::from_bytes(data).await.unwrap();
for num in 0..size + 2 {
assert_eq!(
doc.inner.xref.entries.get(&num).copied(),
sync_xref.get(num),
"entry for object {num}"
);
}
assert_eq!(
doc.inner.xref.trailer.get_ref("Root"),
sync_xref.trailer.get_ref("Root")
);
assert_eq!(
doc.inner.xref.trailer.get_int("Size"),
sync_xref.trailer.get_int("Size")
);
}
#[tokio::test]
async fn classic_document_matches_sync_xref() {
assert_xref_parity(simple_doc("chain walk")).await;
let doc = AsyncDocument::from_bytes(simple_doc("chain walk"))
.await
.unwrap();
assert_eq!(doc.version(), (1, 7));
assert_eq!(doc.inner.sections.len(), 1);
let clone = doc.clone();
assert_eq!(clone.version(), (1, 7));
}
#[tokio::test]
async fn xref_stream_document_matches_sync_xref() {
let (dict, payload) = pdfboss_testkit::objstm_payload(&[
(1, "<< /Type /Catalog /Pages 2 0 R >>"),
(2, "<< /Type /Pages /Kids [] /Count 0 >>"),
]);
let mut b = pdfboss_testkit::PdfBuilder::new();
b.stream(6, &dict, &payload);
assert_xref_parity(b.build_xref_stream(1)).await;
}
fn prev_chain_doc() -> Vec<u8> {
let mut data = b"%PDF-1.5\n".to_vec();
let obj1 = data.len();
data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let obj2_old = data.len();
data.extend_from_slice(b"2 0 obj\n(old)\nendobj\n");
let classic_off = data.len();
data.extend_from_slice(b"xref\n0 3\n0000000000 65535 f\r\n");
data.extend_from_slice(format!("{obj1:010} 00000 n\r\n").as_bytes());
data.extend_from_slice(format!("{obj2_old:010} 00000 n\r\n").as_bytes());
data.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
let obj2_new = data.len();
data.extend_from_slice(b"2 0 obj\n(new)\nendobj\n");
let stream_off = data.len();
let mut fields = Vec::new();
for offset in [obj2_new, stream_off] {
fields.push(1u8);
fields.extend_from_slice(&(offset as u32).to_be_bytes());
fields.extend_from_slice(&0u16.to_be_bytes());
}
data.extend_from_slice(
format!(
"4 0 obj\n<< /Type /XRef /Size 5 /W [1 4 2] /Index [2 1 4 1] \
/Prev {} /Root 1 0 R /Length {} >>\nstream\n",
classic_off,
fields.len()
)
.as_bytes(),
);
data.extend_from_slice(&fields);
data.extend_from_slice(b"\nendstream\nendobj\n");
data.extend_from_slice(format!("startxref\n{stream_off}\n%%EOF\n").as_bytes());
data
}
#[tokio::test]
async fn prev_chain_merges_newest_wins() {
let data = prev_chain_doc();
let obj2_new = pos_of(&data, b"2 0 obj\n(new)") as u64;
assert_xref_parity(data.clone()).await;
let doc = AsyncDocument::from_bytes(data).await.unwrap();
assert!(matches!(
doc.inner.xref.entries.get(&2),
Some(XrefEntry::InFile { offset, .. }) if *offset == obj2_new
));
assert_eq!(doc.inner.sections.len(), 2);
assert!(doc.inner.sections[0].span.start > doc.inner.sections[1].span.start);
assert_eq!(
doc.inner.sections[0].kind,
pdfboss_core::elements::XrefKind::Stream
);
assert_eq!(
doc.inner.sections[1].kind,
pdfboss_core::elements::XrefKind::Table
);
assert_eq!(doc.inner.xref.trailer_span, doc.inner.sections[0].span);
}
fn hybrid_doc() -> Vec<u8> {
let mut data = b"%PDF-1.5\n".to_vec();
let obj1 = data.len();
data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog >>\nendobj\n");
let obj2 = data.len();
data.extend_from_slice(b"2 0 obj\n(hidden)\nendobj\n");
let stm_off = data.len();
let mut fields = Vec::new();
for offset in [obj2, stm_off] {
fields.push(1u8);
fields.extend_from_slice(&(offset as u32).to_be_bytes());
fields.extend_from_slice(&0u16.to_be_bytes());
}
data.extend_from_slice(
format!(
"3 0 obj\n<< /Type /XRef /Size 4 /W [1 4 2] /Index [2 1 3 1] \
/Root 1 0 R /Length {} >>\nstream\n",
fields.len()
)
.as_bytes(),
);
data.extend_from_slice(&fields);
data.extend_from_slice(b"\nendstream\nendobj\n");
let classic_off = data.len();
data.extend_from_slice(b"xref\n0 3\n0000000000 65535 f\r\n");
data.extend_from_slice(format!("{obj1:010} 00000 n\r\n").as_bytes());
data.extend_from_slice(b"0000000000 00001 f\r\n");
data.extend_from_slice(
format!("trailer\n<< /Size 4 /Root 1 0 R /XRefStm {stm_off} >>\n").as_bytes(),
);
data.extend_from_slice(format!("startxref\n{classic_off}\n%%EOF\n").as_bytes());
data
}
#[tokio::test]
async fn hybrid_xrefstm_beats_the_tables_free_entry() {
let data = hybrid_doc();
let obj2 = pos_of(&data, b"2 0 obj\n(hidden)") as u64;
assert_xref_parity(data.clone()).await;
let doc = AsyncDocument::from_bytes(data).await.unwrap();
assert!(matches!(
doc.inner.xref.entries.get(&2),
Some(XrefEntry::InFile { offset, .. }) if *offset == obj2
));
let kinds: Vec<pdfboss_core::elements::XrefKind> =
doc.inner.sections.iter().map(|s| s.kind).collect();
assert_eq!(
kinds,
[
pdfboss_core::elements::XrefKind::Table,
pdfboss_core::elements::XrefKind::Stream
],
"classic section first, then its hybrid /XRefStm section"
);
}
#[tokio::test]
async fn open_reads_from_disk() {
let path =
std::env::temp_dir().join(format!("pdfboss-aio-doc-test-{}.pdf", std::process::id()));
std::fs::write(&path, simple_doc("from disk")).unwrap();
let doc = AsyncDocument::open(&path).await.unwrap();
std::fs::remove_file(&path).ok();
assert_eq!(doc.version(), (1, 7));
}
use pdfboss_core::{ObjRef, Object};
#[tokio::test]
async fn objects_match_the_sync_document() {
for data in [simple_doc("objects"), multi_page_doc(&["a", "b"])] {
let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
let doc = AsyncDocument::from_bytes(data).await.unwrap();
for num in 1..=8u32 {
let r = ObjRef { num, gen: 0 };
match sync_doc.get(r) {
Ok(expected) => {
assert_eq!(doc.get_object(r).await.unwrap(), expected, "object {num}")
}
Err(_) => assert!(doc.get_object(r).await.is_err(), "object {num}"),
}
}
}
}
#[tokio::test]
async fn compressed_objects_are_fetched_from_their_container() {
let (dict, payload) = pdfboss_testkit::objstm_payload(&[
(1, "<< /Type /Catalog /Pages 2 0 R >>"),
(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"),
]);
let mut b = pdfboss_testkit::PdfBuilder::new();
b.stream(6, &dict, &payload);
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>",
);
b.stream(4, "", b"BT (compressed) Tj ET");
let data = b.build_xref_stream(1);
let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
let doc = AsyncDocument::from_bytes(data).await.unwrap();
let font = doc.get_object(ObjRef { num: 5, gen: 0 }).await.unwrap();
assert_eq!(font, sync_doc.get(ObjRef { num: 5, gen: 0 }).unwrap());
assert_eq!(
font.as_dict()
.and_then(|d| d.get_name("BaseFont"))
.map(|n| n.0.as_str()),
Some("Helvetica")
);
let catalog = doc.get_object(ObjRef { num: 1, gen: 0 }).await.unwrap();
assert_eq!(catalog, sync_doc.get(ObjRef { num: 1, gen: 0 }).unwrap());
}
#[tokio::test]
async fn indirect_stream_length_triggers_one_extra_fetch() {
let mut b = pdfboss_testkit::PdfBuilder::new();
b.object(1, "<< /Type /Catalog >>");
b.object(4, "<< /Length 7 0 R >>\nstream\nBT ET\nendstream");
b.object(7, "5");
let data = b.build(1);
let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
let doc = AsyncDocument::from_bytes(data).await.unwrap();
let stream = doc.get_object(ObjRef { num: 4, gen: 0 }).await.unwrap();
assert_eq!(stream, sync_doc.get(ObjRef { num: 4, gen: 0 }).unwrap());
assert_eq!(stream.as_stream().unwrap().data, b"BT ET");
}
#[tokio::test]
async fn objects_larger_than_the_initial_window_grow_until_complete() {
let mut b = pdfboss_testkit::PdfBuilder::new();
b.object(1, "<< /Type /Catalog >>");
let big = vec![b'q'; 5000];
b.stream(4, "", &big);
let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
let object = doc.get_object(ObjRef { num: 4, gen: 0 }).await.unwrap();
assert_eq!(object.as_stream().unwrap().data, big);
}
struct RecordingBackend {
inner: MemBackend,
bytes_read: Arc<std::sync::atomic::AtomicU64>,
}
impl Backend for RecordingBackend {
fn len(&self) -> BoxFuture<'_, std::io::Result<u64>> {
self.inner.len()
}
fn read_at<'a>(
&'a self,
offset: u64,
buf: &'a mut [u8],
) -> BoxFuture<'a, std::io::Result<usize>> {
Box::pin(async move {
let n = self.inner.read_at(offset, buf).await?;
self.bytes_read
.fetch_add(n as u64, std::sync::atomic::Ordering::SeqCst);
Ok(n)
})
}
}
#[tokio::test]
async fn corrupt_offset_in_a_huge_file_errors_within_the_growth_cap_instead_of_reading_to_eof()
{
let header = b"%PDF-1.7\n".to_vec();
let obj1 = b"1 0 obj\n<< /Type /Catalog >>\nendobj\n".to_vec();
let obj1_offset = header.len() as u64;
let mut data = header;
data.extend_from_slice(&obj1);
let garbage_offset = data.len() as u64;
let garbage_len = MAX_GROWTH_WINDOW as usize + 32 * 1024 * 1024;
data.resize(data.len() + garbage_len, b']');
let xref_offset = data.len();
data.extend_from_slice(b"xref\n0 2\n");
data.extend_from_slice(b"0000000000 65535 f\r\n");
data.extend_from_slice(format!("{obj1_offset:010} {:05} n\r\n", 0).as_bytes());
data.extend_from_slice(b"trailer\n<< /Size 2 /Root 1 0 R >>\n");
data.extend_from_slice(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
let bytes_read = Arc::new(std::sync::atomic::AtomicU64::new(0));
let backend = RecordingBackend {
inner: MemBackend::from(data),
bytes_read: Arc::clone(&bytes_read),
};
let doc = AsyncDocument::with_backend(backend)
.await
.expect("a valid one-object catalog with no /Pages still opens");
let mut chain = Vec::new();
let err = doc
.parse_in_file(garbage_offset, &mut chain)
.await
.expect_err("a run of `]` bytes must never parse as an object");
assert!(
err.to_string().contains(&garbage_offset.to_string()),
"error does not name the offending offset: {err}"
);
let total = bytes_read.load(std::sync::atomic::Ordering::SeqCst);
assert!(
total < 3 * MAX_GROWTH_WINDOW,
"read {total} bytes chasing one bogus offset -- the growth cap did not bound it"
);
}
#[tokio::test]
async fn resolve_mirrors_sync_lenient_semantics() {
let mut b = pdfboss_testkit::PdfBuilder::new();
b.object(1, "<< /Type /Catalog >>");
b.object(6, "6 0 R");
let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
let missing = Object::Ref(ObjRef { num: 99, gen: 0 });
assert_eq!(doc.resolve(&missing).await.unwrap(), Object::Null);
let loops = Object::Ref(ObjRef { num: 6, gen: 0 });
assert!(matches!(
doc.resolve(&loops).await,
Err(Error::Core(pdfboss_core::Error::CircularReference(6)))
));
let catalog = doc.get_object(ObjRef { num: 1, gen: 7 }).await.unwrap();
assert!(catalog.as_dict().is_some());
}
#[tokio::test]
async fn decode_stream_matches_sync_stream_data() {
let data = simple_doc("stream parity");
let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
let doc = AsyncDocument::from_bytes(data).await.unwrap();
let object = doc.get_object(ObjRef { num: 4, gen: 0 }).await.unwrap();
let stream = object.as_stream().unwrap();
assert_eq!(
doc.decode_stream(stream).await.unwrap(),
sync_doc.stream_data(stream).unwrap()
);
}
#[tokio::test]
async fn read_span_returns_raw_file_bytes() {
let data = simple_doc("raw bytes");
let doc = AsyncDocument::from_bytes(data.clone()).await.unwrap();
let slice = doc.read_span(Span { start: 0, end: 8 }).await.unwrap();
assert_eq!(slice, b"%PDF-1.7");
let file_len = data.len() as u64;
assert_eq!(doc.file_len(), file_len);
let tail = doc
.read_span(Span {
start: file_len - 6,
end: file_len + 50,
})
.await
.unwrap();
assert_eq!(tail, b"%%EOF\n");
assert!(doc
.read_span(Span {
start: file_len + 1,
end: file_len + 2
})
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn metadata_matches_the_sync_document() {
let mut b = pdfboss_testkit::PdfBuilder::new().trailer_extra("/Info 6 0 R");
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
b.object(6, "<< /Title <FEFF00480151> /Author (plain author) >>");
let data = b.build(1);
let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
let doc = AsyncDocument::from_bytes(data).await.unwrap();
let meta = doc.metadata().await.unwrap();
assert_eq!(meta, sync_doc.metadata());
assert_eq!(meta.title.as_deref(), Some("H\u{151}"));
assert_eq!(meta.author.as_deref(), Some("plain author"));
assert_eq!(meta.subject, None);
}
#[tokio::test]
async fn metadata_without_info_is_all_none() {
let doc = AsyncDocument::from_bytes(simple_doc("x")).await.unwrap();
assert_eq!(
doc.metadata().await.unwrap(),
pdfboss_core::Metadata::default()
);
}
#[tokio::test]
async fn page_count_matches_the_sync_document() {
for (data, expected) in [
(simple_doc("one"), 1usize),
(multi_page_doc(&["a", "b", "c"]), 3usize),
] {
let sync_doc = pdfboss_core::Document::load(data.clone()).unwrap();
let doc = AsyncDocument::from_bytes(data).await.unwrap();
assert_eq!(doc.page_count(), expected);
assert_eq!(doc.page_count(), sync_doc.page_count());
}
}
#[tokio::test]
async fn page_records_carry_inherited_resources_and_refs() {
let mut b = pdfboss_testkit::PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(
2,
"<< /Type /Pages /Kids [3 0 R] /Count 1 \
/Resources << /Font << /F1 5 0 R >> >> >>",
);
b.object(3, "<< /Type /Page /Parent 2 0 R >>");
b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
assert_eq!(doc.page_count(), 1);
let record = doc.page_record(0).unwrap();
assert_eq!(record.r, Some(ObjRef { num: 3, gen: 0 }));
assert!(
record.resources.get("Font").is_some(),
"inherited resources"
);
assert!(doc.page_record(1).is_none());
}
#[tokio::test]
async fn kids_cycle_truncates_without_hanging() {
let mut b = pdfboss_testkit::PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(3, "<< /Type /Pages /Kids [4 0 R 2 0 R] /Count 1 >>");
b.object(4, "<< /Type /Page /Parent 3 0 R /MediaBox [0 0 100 100] >>");
let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
assert_eq!(doc.page_count(), 1, "cycle back-edge yields no extra pages");
}
#[tokio::test]
async fn page_count_is_the_flattened_length() {
let mut b = pdfboss_testkit::PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 5 >>");
b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
let doc = AsyncDocument::from_bytes(b.build(1)).await.unwrap();
assert_eq!(doc.page_count(), 1);
}
#[tokio::test]
async fn encrypted_documents_are_rejected_at_open() {
let mut b = pdfboss_testkit::PdfBuilder::new().trailer_extra("/Encrypt 9 0 R");
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
b.object(
9,
"<< /Filter /Standard /V 1 /R 2 /O (dummydummydummydummydummydummyd) \
/U (dummydummydummydummydummydummyd) /P -3904 >>",
);
let data = b.build(1);
assert!(
matches!(
AsyncDocument::from_bytes(data).await,
Err(Error::Core(pdfboss_core::Error::Encrypted))
),
"an encrypted document must be rejected at open, not opened with garbage reads"
);
}
}