use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use oxideav_core::vector::{
FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, Transform2D,
VectorFrame,
};
use oxideav_core::TimeBase;
use oxideav_scene::{Metadata, Page, Scene};
use crate::decrypt::{open_with_password, StandardHandler};
use crate::error::PdfError;
use crate::objects::{Dict, Object, ObjectId, Stream};
use crate::pubsec::{
open_with_certificate, open_with_certificate_and_trust_store, PubSecCredential, TrustStore,
};
use crate::reader::content::{
parse_content_stream_full_with_tiling, parse_content_stream_full_with_type3, TilingPattern,
Type3Font,
};
use crate::reader::parse::Parser;
use crate::reader::xref::{parse_xref, XrefEntry, XrefTable};
pub struct DocumentReader<'a> {
input: &'a [u8],
xref: XrefTable,
cache: HashMap<ObjectId, Object>,
crypt: Option<StandardHandler>,
objstm_cache: HashMap<u32, ObjStmDecoded>,
}
struct ObjStmDecoded {
payload: Vec<u8>,
slots: Vec<(u32, usize)>,
}
impl<'a> DocumentReader<'a> {
pub fn open(input: &'a [u8]) -> Result<Self, PdfError> {
Self::open_with_password(input, b"")
}
pub fn open_with_password(input: &'a [u8], password: &[u8]) -> Result<Self, PdfError> {
let xref = parse_xref(input)?;
let crypt = build_crypt(&xref, input, password)?;
Ok(Self {
input,
xref,
cache: HashMap::new(),
objstm_cache: HashMap::new(),
crypt,
})
}
pub fn open_with_certificate(
input: &'a [u8],
credential: &PubSecCredential,
) -> Result<Self, PdfError> {
let xref = parse_xref(input)?;
let crypt = build_crypt_pubsec(&xref, input, credential, None)?;
Ok(Self {
input,
xref,
cache: HashMap::new(),
objstm_cache: HashMap::new(),
crypt,
})
}
pub fn open_with_certificate_and_trust_store(
input: &'a [u8],
credential: &PubSecCredential,
trust_store: &TrustStore,
) -> Result<Self, PdfError> {
let xref = parse_xref(input)?;
let crypt = build_crypt_pubsec(&xref, input, credential, Some(trust_store))?;
Ok(Self {
input,
xref,
cache: HashMap::new(),
objstm_cache: HashMap::new(),
crypt,
})
}
pub fn xref(&self) -> &XrefTable {
&self.xref
}
pub fn is_encrypted(&self) -> bool {
self.crypt.is_some()
}
pub fn signatures(&mut self) -> Result<Vec<crate::reader::sig::PdfSignature>, PdfError> {
crate::reader::sig::signatures(self)
}
pub fn doc_timestamps(&mut self) -> Result<Vec<crate::reader::sig::PdfDocTimestamp>, PdfError> {
crate::reader::sig::doc_timestamps(self)
}
pub fn annotations(
&mut self,
) -> Result<Vec<crate::reader::annotation::PdfAnnotation>, PdfError> {
crate::reader::annotation::annotations(self)
}
pub fn actions(&mut self) -> Result<Vec<crate::reader::actions::PdfAction>, PdfError> {
crate::reader::actions::actions(self)
}
pub fn optional_content(
&mut self,
) -> Result<Option<crate::reader::ocg::OptionalContent>, PdfError> {
crate::reader::ocg::optional_content(self)
}
pub fn linearization(
&self,
) -> Result<Option<crate::reader::linearize::LinearizationParams>, PdfError> {
crate::reader::linearize::LinearizationParams::parse(self.input)
}
pub fn verify_hierarchy(
&mut self,
) -> Result<crate::reader::hierarchy::HierarchyReport, PdfError> {
crate::reader::hierarchy::verify_hierarchy(self)
}
pub fn pdfa_signals(&mut self) -> Result<crate::reader::pdfa::PdfACatalogSignals, PdfError> {
crate::reader::pdfa::pdfa_signals(self)
}
pub fn pdfa_conformance(&mut self) -> Result<crate::reader::pdfa::PdfAConformance, PdfError> {
let signals = self.pdfa_signals()?;
let xmp = self.xmp_packet()?;
Ok(crate::reader::pdfa::PdfAConformance::from_signals_and_xmp(
&signals,
xmp.as_ref(),
))
}
pub fn xmp_packet(&mut self) -> Result<Option<crate::reader::xmp::XmpPacket>, PdfError> {
Ok(self
.xmp_metadata()?
.as_deref()
.map(crate::reader::xmp::XmpPacket::parse))
}
pub fn xmp_metadata(&mut self) -> Result<Option<Vec<u8>>, PdfError> {
let root_id = self.xref.root()?;
let catalog = self.resolve(root_id)?;
let Object::Dict(catalog) = catalog else {
return Err(PdfError::other(format!(
"PDF reader: /Root must be a dictionary (got {catalog:?})"
)));
};
let metadata_obj = catalog
.entries()
.iter()
.find(|(k, _)| k == "Metadata")
.map(|(_, v)| v.clone());
let Some(metadata_obj) = metadata_obj else {
return Ok(None);
};
let stream_obj = match metadata_obj {
Object::Reference(id) => self.resolve(id)?,
other => other,
};
let Object::Stream(s) = stream_obj else {
return Err(PdfError::other(format!(
"PDF reader: catalog /Metadata must resolve to a Stream (got {stream_obj:?})"
)));
};
Ok(Some(decode_stream(&s)?))
}
pub fn resolve(&mut self, id: ObjectId) -> Result<Object, PdfError> {
if let Some(o) = self.cache.get(&id) {
return Ok(o.clone());
}
if let Some(XrefEntry::Compressed {
obj_stream_id,
index_within_stream,
}) = self.xref.entries.get(&id.number).copied()
{
let body = self.resolve_compressed(id, obj_stream_id, index_within_stream)?;
self.cache.insert(id, body.clone());
return Ok(body);
}
let off = self
.xref
.offset_of(id)
.ok_or_else(|| PdfError::other(format!("PDF reader: object {id:?} not in xref")))?;
let mut p = Parser::new(self.input);
p.lexer_mut().seek(off as usize);
let input = self.input;
let xref_snapshot = &self.xref;
let id_being_parsed = id;
let mut resolver = move |length_ref_id: ObjectId| -> Result<i64, PdfError> {
resolve_indirect_length(input, xref_snapshot, length_ref_id, id_being_parsed)
};
let (parsed_id, mut body) = p.parse_indirect_with_length_resolver(&mut resolver)?;
if parsed_id != id {
return Err(PdfError::other(format!(
"PDF reader: xref points to wrong object — wanted {id:?}, got {parsed_id:?}"
)));
}
if let Some(crypt) = &self.crypt {
decrypt_object_in_place(&mut body, id, crypt)?;
}
self.cache.insert(id, body.clone());
Ok(body)
}
fn resolve_compressed(
&mut self,
wanted: ObjectId,
obj_stream_num: u32,
index_within_stream: u32,
) -> Result<Object, PdfError> {
if let Some(decoded) = self.objstm_cache.get(&obj_stream_num) {
return Self::slot_from_decoded(decoded, wanted, index_within_stream);
}
let decoded = self.decode_objstm_container(wanted, obj_stream_num)?;
let body = Self::slot_from_decoded(&decoded, wanted, index_within_stream)?;
self.objstm_cache.insert(obj_stream_num, decoded);
Ok(body)
}
fn decode_objstm_container(
&mut self,
wanted: ObjectId,
obj_stream_num: u32,
) -> Result<ObjStmDecoded, PdfError> {
let container_id = ObjectId::new(obj_stream_num);
if let Some(XrefEntry::Compressed { .. }) = self.xref.entries.get(&container_id.number) {
return Err(PdfError::other(format!(
"PDF reader: ObjStm container {container_id:?} (for compressed object \
{wanted:?}) is itself declared as a Type-2 compressed entry in the xref \
— forbidden by ISO 32000-1 §7.5.7"
)));
}
let container = self.resolve(container_id)?;
let Object::Stream(s) = container else {
return Err(PdfError::other(format!(
"PDF reader: object {wanted:?} expected its container {container_id:?} to be a Stream"
)));
};
let dict = &s.dict;
let lookup = |k: &str| {
dict.entries()
.iter()
.find(|(kk, _)| kk == k)
.map(|(_, v)| v.clone())
};
if !matches!(lookup("Type"), Some(Object::Name(ref n)) if n == "ObjStm") {
return Err(PdfError::other(format!(
"PDF reader: container {container_id:?} is not /Type /ObjStm"
)));
}
let n = match lookup("N") {
Some(Object::Integer(v)) if v >= 0 => v as u32,
other => {
return Err(PdfError::other(format!(
"PDF reader: ObjStm /N must be a non-negative integer (got {other:?})"
)))
}
};
let first = match lookup("First") {
Some(Object::Integer(v)) if v >= 0 => v as usize,
other => {
return Err(PdfError::other(format!(
"PDF reader: ObjStm /First must be a non-negative integer (got {other:?})"
)))
}
};
let payload = decode_stream(&s)?;
if first > payload.len() {
return Err(PdfError::other(format!(
"PDF reader: ObjStm /First {first} exceeds payload length {}",
payload.len()
)));
}
let header_bytes = &payload[..first];
let mut hp = Parser::new(header_bytes);
let mut slots: Vec<(u32, usize)> = Vec::with_capacity(n as usize);
for i in 0..n {
let on = hp.parse_object()?.ok_or_else(|| {
PdfError::other(format!(
"PDF reader: ObjStm header truncated at pair {i} (obj_num)"
))
})?;
let off = hp.parse_object()?.ok_or_else(|| {
PdfError::other(format!(
"PDF reader: ObjStm header truncated at pair {i} (offset)"
))
})?;
let (Object::Integer(num), Object::Integer(o)) = (on, off) else {
return Err(PdfError::other(format!(
"PDF reader: ObjStm header pair {i} must be two integers"
)));
};
if num < 1 || o < 0 {
return Err(PdfError::other(format!(
"PDF reader: ObjStm header pair {i} out of range ({num}, {o})"
)));
}
let abs_off = first
.checked_add(o as usize)
.ok_or_else(|| PdfError::other("PDF reader: ObjStm offset overflow"))?;
if abs_off > payload.len() {
return Err(PdfError::other(format!(
"PDF reader: ObjStm body offset {abs_off} past payload length {}",
payload.len()
)));
}
slots.push((num as u32, abs_off));
}
Ok(ObjStmDecoded { payload, slots })
}
fn slot_from_decoded(
decoded: &ObjStmDecoded,
wanted: ObjectId,
index_within_stream: u32,
) -> Result<Object, PdfError> {
let n = decoded.slots.len() as u32;
if index_within_stream >= n {
return Err(PdfError::other(format!(
"PDF reader: ObjStm index {index_within_stream} out of range (N={n})"
)));
}
let (header_num, abs_off) = decoded.slots[index_within_stream as usize];
if header_num != wanted.number {
return Err(PdfError::other(format!(
"PDF reader: ObjStm slot {index_within_stream} declares object {header_num},\
but xref expected {}",
wanted.number
)));
}
let mut bp = Parser::new(&decoded.payload[abs_off..]);
let body = bp
.parse_object()?
.ok_or_else(|| PdfError::other("PDF reader: ObjStm body parse returned EOF"))?;
Ok(body)
}
pub fn deref(&mut self, obj: Object) -> Result<Object, PdfError> {
let mut cur = obj;
let mut hops = 0;
while let Object::Reference(id) = cur {
cur = self.resolve(id)?;
hops += 1;
if hops > 16 {
return Err(PdfError::other(
"PDF reader: indirect-reference chain too deep (>16 hops)",
));
}
}
Ok(cur)
}
}
fn resolve_indirect_length(
input: &[u8],
xref: &XrefTable,
length_ref: ObjectId,
containing_stream: ObjectId,
) -> Result<i64, PdfError> {
if length_ref == containing_stream {
return Err(PdfError::other(format!(
"PDF reader: stream {containing_stream:?} /Length refers to itself"
)));
}
if let Some(XrefEntry::Compressed { .. }) = xref.entries.get(&length_ref.number) {
return Err(PdfError::other(format!(
"PDF reader: indirect /Length {length_ref:?} lives in an object stream \
— not supported (ISO 32000-1 §7.5.7); the length object should be a \
direct uncompressed integer"
)));
}
let off = xref.offset_of(length_ref).ok_or_else(|| {
PdfError::other(format!(
"PDF reader: indirect /Length {length_ref:?} (for stream \
{containing_stream:?}) is not in the xref table"
))
})?;
let mut p = Parser::new(input);
p.lexer_mut().seek(off as usize);
let (parsed_id, body) = p.parse_indirect()?;
if parsed_id != length_ref {
return Err(PdfError::other(format!(
"PDF reader: xref points to wrong object for indirect /Length — \
wanted {length_ref:?}, got {parsed_id:?}"
)));
}
match body {
Object::Integer(n) => Ok(n),
other => Err(PdfError::other(format!(
"PDF reader: indirect /Length {length_ref:?} target must be an \
integer (got {other:?})"
))),
}
}
fn build_crypt(
xref: &XrefTable,
input: &[u8],
password: &[u8],
) -> Result<Option<StandardHandler>, PdfError> {
let encrypt_ref = xref.trailer.entries().iter().find(|(k, _)| k == "Encrypt");
let Some((_, encrypt_obj)) = encrypt_ref else {
return Ok(None);
};
let encrypt_dict = match encrypt_obj {
Object::Dict(d) => d.clone(),
Object::Reference(id) => {
let off = xref
.offset_of(*id)
.ok_or_else(|| PdfError::other("PDF reader: /Encrypt refers to missing object"))?;
let mut p = Parser::new(input);
p.lexer_mut().seek(off as usize);
let (_, body) = p.parse_indirect()?;
match body {
Object::Dict(d) => d,
other => {
return Err(PdfError::other(format!(
"PDF reader: /Encrypt must resolve to a dict (got {other:?})"
)))
}
}
}
other => {
return Err(PdfError::other(format!(
"PDF reader: /Encrypt must be a dict or reference (got {other:?})"
)))
}
};
let id_obj = xref
.trailer
.entries()
.iter()
.find(|(k, _)| k == "ID")
.map(|(_, v)| v.clone())
.ok_or_else(|| PdfError::other("PDF reader: encrypted PDF missing /ID in trailer"))?;
let Object::Array(id_items) = id_obj else {
return Err(PdfError::other("PDF reader: /ID must be an array"));
};
if id_items.is_empty() {
return Err(PdfError::other("PDF reader: trailer /ID array is empty"));
}
let file_id = match &id_items[0] {
Object::LiteralString(s) | Object::HexString(s) => s.clone(),
other => {
return Err(PdfError::other(format!(
"PDF reader: /ID[0] must be a string (got {other:?})"
)))
}
};
let handler = open_with_password(&encrypt_dict, &file_id, password)?;
handler
.ok_or_else(|| {
PdfError::other("PDF reader: wrong password (or PDF requires owner password)")
})
.map(Some)
}
fn build_crypt_pubsec(
xref: &XrefTable,
input: &[u8],
credential: &PubSecCredential,
trust_store: Option<&TrustStore>,
) -> Result<Option<StandardHandler>, PdfError> {
let encrypt_ref = xref.trailer.entries().iter().find(|(k, _)| k == "Encrypt");
let Some((_, encrypt_obj)) = encrypt_ref else {
return Ok(None);
};
let encrypt_dict = match encrypt_obj {
Object::Dict(d) => d.clone(),
Object::Reference(id) => {
let off = xref
.offset_of(*id)
.ok_or_else(|| PdfError::other("PDF reader: /Encrypt refers to missing object"))?;
let mut p = Parser::new(input);
p.lexer_mut().seek(off as usize);
let (_, body) = p.parse_indirect()?;
match body {
Object::Dict(d) => d,
other => {
return Err(PdfError::other(format!(
"PDF reader: /Encrypt must resolve to a dict (got {other:?})"
)))
}
}
}
other => {
return Err(PdfError::other(format!(
"PDF reader: /Encrypt must be a dict or reference (got {other:?})"
)))
}
};
let handler = match trust_store {
Some(store) => open_with_certificate_and_trust_store(&encrypt_dict, credential, store)?,
None => open_with_certificate(&encrypt_dict, credential)?,
};
handler
.ok_or_else(|| {
PdfError::other(
"PDF reader: certificate did not match any recipient in /Recipients (round-10)",
)
})
.map(Some)
}
fn decrypt_object_in_place(
obj: &mut Object,
id: ObjectId,
crypt: &StandardHandler,
) -> Result<(), PdfError> {
match obj {
Object::LiteralString(s) | Object::HexString(s) => {
*s = crypt.decrypt_object(id, s)?;
}
Object::Array(items) => {
for item in items {
decrypt_object_in_place(item, id, crypt)?;
}
}
Object::Dict(d) => {
decrypt_dict_in_place(d, id, crypt)?;
}
Object::Stream(s) => {
decrypt_dict_in_place(&mut s.dict, id, crypt)?;
if has_identity_crypt_filter(&s.dict) {
return Ok(());
}
s.data = crypt.decrypt_object(id, &s.data)?;
}
_ => {}
}
Ok(())
}
fn has_identity_crypt_filter(dict: &Dict) -> bool {
let filter = dict
.entries()
.iter()
.find(|(k, _)| k == "Filter")
.map(|(_, v)| v);
let parms = dict
.entries()
.iter()
.find(|(k, _)| k == "DecodeParms")
.map(|(_, v)| v);
let crypt_pos: Option<usize> = match filter {
Some(Object::Name(s)) if s == "Crypt" => Some(0),
Some(Object::Array(items)) => items
.iter()
.position(|f| matches!(f, Object::Name(n) if n == "Crypt")),
_ => None,
};
let Some(idx) = crypt_pos else {
return false;
};
let parms_dict = match parms {
Some(Object::Dict(d)) if idx == 0 => Some(d.clone()),
Some(Object::Array(items)) => match items.get(idx) {
Some(Object::Dict(d)) => Some(d.clone()),
_ => None,
},
_ => None,
};
let Some(d) = parms_dict else {
return true;
};
match d
.entries()
.iter()
.find(|(k, _)| k == "Name")
.map(|(_, v)| v)
{
Some(Object::Name(s)) => s == "Identity",
None => true,
_ => false,
}
}
fn decrypt_dict_in_place(
d: &mut Dict,
id: ObjectId,
crypt: &StandardHandler,
) -> Result<(), PdfError> {
let mut new_entries: Vec<(String, Object)> = Vec::with_capacity(d.entries().len());
for (k, v) in d.entries() {
let mut v = v.clone();
decrypt_object_in_place(&mut v, id, crypt)?;
new_entries.push((k.clone(), v));
}
*d = Dict::default();
for (k, v) in new_entries {
d.set(&k, v);
}
Ok(())
}
pub fn read_pdf_to_scene(input: &[u8]) -> Result<Scene, PdfError> {
read_pdf_to_scene_with_password(input, b"")
}
pub fn read_pdf_to_scene_with_password(input: &[u8], password: &[u8]) -> Result<Scene, PdfError> {
let reader = DocumentReader::open_with_password(input, password)?;
decode_to_scene(reader)
}
pub fn read_pdf_to_scene_with_certificate(
input: &[u8],
credential: &PubSecCredential,
) -> Result<Scene, PdfError> {
let reader = DocumentReader::open_with_certificate(input, credential)?;
decode_to_scene(reader)
}
pub fn read_pdf_to_scene_with_certificate_and_trust_store(
input: &[u8],
credential: &PubSecCredential,
trust_store: &TrustStore,
) -> Result<Scene, PdfError> {
let reader =
DocumentReader::open_with_certificate_and_trust_store(input, credential, trust_store)?;
decode_to_scene(reader)
}
fn decode_to_scene(mut reader: DocumentReader<'_>) -> Result<Scene, PdfError> {
let root_id = reader.xref.root()?;
let catalog = reader.resolve(root_id)?;
let Object::Dict(catalog) = catalog else {
return Err(PdfError::other(format!(
"PDF reader: /Root must be a dictionary (got {catalog:?})"
)));
};
let pages_ref = catalog
.entries()
.iter()
.find(|(k, _)| k == "Pages")
.map(|(_, v)| v.clone())
.ok_or_else(|| PdfError::other("PDF reader: catalog missing /Pages"))?;
let Object::Reference(pages_root_id) = pages_ref else {
return Err(PdfError::other(format!(
"PDF reader: catalog /Pages must be an indirect reference (got {pages_ref:?})"
)));
};
let mut leaves = Vec::new();
walk_pages_tree(&mut reader, pages_root_id, &mut leaves)?;
if leaves.is_empty() {
return Err(PdfError::other(
"PDF reader: /Pages tree contained no Page leaves",
));
}
let optional_content = crate::reader::ocg::optional_content(&mut reader).unwrap_or(None);
let mut scene_pages = Vec::with_capacity(leaves.len());
for leaf_id in leaves {
scene_pages.push(decode_page(
&mut reader,
leaf_id,
optional_content.as_ref(),
)?);
}
let metadata = if let Some(info_id) = reader.xref.info() {
let info = reader.resolve(info_id)?;
decode_metadata(info)?
} else {
Metadata::default()
};
Ok(Scene {
pages: Some(scene_pages),
metadata,
..Scene::default()
})
}
const MAX_PAGES_TREE_DEPTH: u32 = 256;
fn walk_pages_tree(
reader: &mut DocumentReader<'_>,
node_id: ObjectId,
out: &mut Vec<ObjectId>,
) -> Result<(), PdfError> {
let mut visited = HashSet::new();
walk_pages_tree_inner(reader, node_id, out, &mut visited, 0)
}
fn walk_pages_tree_inner(
reader: &mut DocumentReader<'_>,
node_id: ObjectId,
out: &mut Vec<ObjectId>,
visited: &mut HashSet<ObjectId>,
depth: u32,
) -> Result<(), PdfError> {
if depth > MAX_PAGES_TREE_DEPTH {
return Err(PdfError::other(format!(
"PDF reader: /Pages tree exceeds maximum depth ({MAX_PAGES_TREE_DEPTH})"
)));
}
if !visited.insert(node_id) {
return Err(PdfError::other(format!(
"PDF reader: /Pages tree contains a cycle at {node_id:?}"
)));
}
let node = reader.resolve(node_id)?;
let Object::Dict(d) = node else {
return Err(PdfError::other(format!(
"PDF reader: pages-tree node {node_id:?} is not a dict"
)));
};
let kind = d
.entries()
.iter()
.find(|(k, _)| k == "Type")
.and_then(|(_, v)| match v {
Object::Name(s) => Some(s.as_str()),
_ => None,
});
match kind {
Some("Page") => {
out.push(node_id);
Ok(())
}
Some("Pages") => {
let kids = d
.entries()
.iter()
.find(|(k, _)| k == "Kids")
.map(|(_, v)| v.clone())
.ok_or_else(|| {
PdfError::other(format!("PDF reader: /Pages node {node_id:?} missing /Kids"))
})?;
let Object::Array(items) = kids else {
return Err(PdfError::other(format!(
"PDF reader: /Kids must be an array on {node_id:?}"
)));
};
for item in items {
if let Object::Reference(id) = item {
walk_pages_tree_inner(reader, id, out, visited, depth + 1)?;
}
}
Ok(())
}
_ => Err(PdfError::other(format!(
"PDF reader: pages-tree node {node_id:?} has unknown /Type {kind:?}"
))),
}
}
const MAX_PAGE_TREE_DEPTH: usize = 64;
fn resolve_inheritable_attr(
reader: &mut DocumentReader<'_>,
page_dict: &Dict,
key: &str,
) -> Result<Option<Object>, PdfError> {
if let Some((_, v)) = page_dict.entries().iter().find(|(k, _)| k == key) {
return Ok(Some(v.clone()));
}
let mut parent = page_dict
.entries()
.iter()
.find(|(k, _)| k == "Parent")
.map(|(_, v)| v.clone());
let mut visited: HashSet<ObjectId> = HashSet::new();
let mut depth = 0;
while let Some(Object::Reference(parent_id)) = parent {
if depth >= MAX_PAGE_TREE_DEPTH || !visited.insert(parent_id) {
break;
}
depth += 1;
let Object::Dict(node) = reader.resolve(parent_id)? else {
break;
};
if let Some((_, v)) = node.entries().iter().find(|(k, _)| k == key) {
return Ok(Some(v.clone()));
}
parent = node
.entries()
.iter()
.find(|(k, _)| k == "Parent")
.map(|(_, v)| v.clone());
}
Ok(None)
}
fn decode_page(
reader: &mut DocumentReader<'_>,
page_id: ObjectId,
optional_content: Option<&crate::reader::ocg::OptionalContent>,
) -> Result<Page, PdfError> {
let page_obj = reader.resolve(page_id)?;
let Object::Dict(page_dict) = page_obj else {
return Err(PdfError::other(format!(
"PDF reader: page {page_id:?} is not a dict"
)));
};
let media_box = resolve_inheritable_attr(reader, &page_dict, "MediaBox")?;
let (width, height) = match media_box {
Some(Object::Array(items)) if items.len() == 4 => {
let llx = number_to_f32(&items[0])?;
let lly = number_to_f32(&items[1])?;
let urx = number_to_f32(&items[2])?;
let ury = number_to_f32(&items[3])?;
((urx - llx).abs(), (ury - lly).abs())
}
Some(other) => {
return Err(PdfError::other(format!(
"PDF reader: /MediaBox must be a 4-array (got {other:?})"
)));
}
None => {
(595.0, 842.0)
}
};
let contents_obj = page_dict
.entries()
.iter()
.find(|(k, _)| k == "Contents")
.map(|(_, v)| v.clone());
let content_bytes = match contents_obj {
Some(Object::Reference(id)) => extract_stream_data(reader, id)?,
Some(Object::Array(items)) => {
let mut all = Vec::new();
for item in items {
if let Object::Reference(id) = item {
all.extend_from_slice(&extract_stream_data(reader, id)?);
all.push(b'\n');
}
}
all
}
Some(other) => {
return Err(PdfError::other(format!(
"PDF reader: /Contents must be a Stream or array (got {other:?})"
)));
}
None => Vec::new(),
};
let resources_obj = resolve_inheritable_attr(reader, &page_dict, "Resources")?;
let resources_dict = match resources_obj {
Some(Object::Reference(id)) => match reader.resolve(id)? {
Object::Dict(d) => Some(d),
_ => None,
},
Some(Object::Dict(d)) => Some(d),
_ => None,
};
let ext_gstate_dict = if let Some(rdict) = resources_dict.as_ref() {
resolve_ext_gstate(reader, rdict)?
} else {
None
};
let fonts_dict = if let Some(rdict) = resources_dict.as_ref() {
resolve_font_resources(reader, rdict)?
} else {
None
};
let shading_dict = if let Some(rdict) = resources_dict.as_ref() {
resolve_shading_resources(reader, rdict)?
} else {
None
};
let color_space_dict = if let Some(rdict) = resources_dict.as_ref() {
resolve_color_space_resources(reader, rdict)?
} else {
None
};
let properties_dict = if let Some(rdict) = resources_dict.as_ref() {
resolve_properties_resources(reader, rdict)?
} else {
None
};
let xobject_forms = if let Some(rdict) = resources_dict.as_ref() {
let mut seen = HashSet::new();
resolve_xobject_forms(reader, rdict, 0, &mut seen)?
} else {
None
};
let pattern_dict = if let Some(rdict) = resources_dict.as_ref() {
resolve_pattern_resources(reader, rdict)?
} else {
None
};
let tiling_patterns = if let Some(rdict) = resources_dict.as_ref() {
resolve_tiling_patterns(reader, rdict, 0)?
} else {
None
};
let type3_fonts = if let Some(rdict) = resources_dict.as_ref() {
resolve_type3_fonts(reader, rdict, 0)?
} else {
None
};
let parsed = parse_content_stream_full_with_type3(
&content_bytes,
ext_gstate_dict.as_ref(),
fonts_dict.as_ref(),
shading_dict.as_ref(),
color_space_dict.as_ref(),
properties_dict.as_ref(),
xobject_forms.as_ref(),
pattern_dict.as_ref(),
tiling_patterns.as_ref(),
type3_fonts.as_ref(),
)?;
let mut root = parsed.root;
let annot_groups = resolve_annotation_appearances(reader, &page_dict, optional_content)?;
if !annot_groups.is_empty() {
if root.transform != Transform2D::identity() || root.clip.is_some() || root.opacity != 1.0 {
root = Group {
children: vec![Node::Group(root)],
..Group::default()
};
}
root.children
.extend(annot_groups.into_iter().map(Node::Group));
}
let mut page = Page::new(width, height);
if let Some(Object::Integer(deg)) = resolve_inheritable_attr(reader, &page_dict, "Rotate")? {
if deg % 90 == 0 {
page.orientation = (deg.rem_euclid(360)) as u16;
}
}
page.content = VectorFrame {
width,
height,
view_box: None,
root,
pts: None,
time_base: TimeBase::new(1, 1),
};
Ok(page)
}
fn resolve_ext_gstate(
reader: &mut DocumentReader<'_>,
resources: &Dict,
) -> Result<Option<Dict>, PdfError> {
let ext_obj = resources
.entries()
.iter()
.find(|(k, _)| k == "ExtGState")
.map(|(_, v)| v.clone());
let ext_obj = match ext_obj {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(ext_dict) = ext_obj else {
return Ok(None);
};
let mut out = Dict::new();
for (name, value) in ext_dict.entries() {
let resolved = match value {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
if let Object::Dict(d) = resolved {
out.set(name, Object::Dict(d));
}
}
Ok(Some(out))
}
fn resolve_font_resources(
reader: &mut DocumentReader<'_>,
resources: &Dict,
) -> Result<Option<Dict>, PdfError> {
let font_obj = resources
.entries()
.iter()
.find(|(k, _)| k == "Font")
.map(|(_, v)| v.clone());
let font_obj = match font_obj {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(font_dict) = font_obj else {
return Ok(None);
};
let mut out = Dict::new();
for (name, value) in font_dict.entries() {
let resolved = match value {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
if let Object::Dict(mut d) = resolved {
resolve_font_widths(reader, &mut d)?;
out.set(name, Object::Dict(d));
}
}
Ok(Some(out))
}
fn resolve_font_widths(reader: &mut DocumentReader<'_>, font: &mut Dict) -> Result<(), PdfError> {
if let Some(Object::Reference(id)) =
font.entries()
.iter()
.find_map(|(k, v)| if k == "Widths" { Some(v.clone()) } else { None })
{
let resolved = reader.resolve(id)?;
font.set("Widths", resolved);
}
if let Some(Object::Reference(id)) = font.entries().iter().find_map(|(k, v)| {
if k == "FontDescriptor" {
Some(v.clone())
} else {
None
}
}) {
if let Ok(Object::Dict(d)) = reader.resolve(id) {
font.set("FontDescriptor", Object::Dict(d));
}
}
let descendant_ref = font.entries().iter().find_map(|(k, v)| {
if k == "DescendantFonts" {
Some(v.clone())
} else {
None
}
});
if let Some(obj) = descendant_ref {
let array_obj = match obj {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
let cid_ref = match array_obj {
Object::Array(items) => items.into_iter().next(),
Object::Dict(d) => Some(Object::Dict(d)),
_ => None,
};
if let Some(cid_obj) = cid_ref {
let cid_obj = match cid_obj {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
if let Object::Dict(mut cid_font) = cid_obj {
if let Some(Object::Reference(id)) = cid_font.entries().iter().find_map(|(k, v)| {
if k == "W" {
Some(v.clone())
} else {
None
}
}) {
let resolved = reader.resolve(id)?;
cid_font.set("W", resolved);
}
font.set("DescendantFonts", Object::Dict(cid_font));
}
}
}
Ok(())
}
fn resolve_shading_resources(
reader: &mut DocumentReader<'_>,
resources: &Dict,
) -> Result<Option<Dict>, PdfError> {
let shading_obj = resources
.entries()
.iter()
.find(|(k, _)| k == "Shading")
.map(|(_, v)| v.clone());
let shading_obj = match shading_obj {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(shading_dict) = shading_obj else {
return Ok(None);
};
let mut out = Dict::new();
for (name, value) in shading_dict.entries() {
let resolved = match value {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
let d = match resolved {
Object::Dict(d) => Some(d),
Object::Stream(s) => {
let mesh = decode_stream(&s)?;
let mut d = s.dict;
d.set("__MeshData", Object::HexString(mesh));
Some(d)
}
_ => None,
};
if let Some(mut d) = d {
if let Some((_, fobj)) = d
.entries()
.iter()
.find(|(k, _)| k == "Function")
.map(|(k, v)| (k.clone(), v.clone()))
{
let fobj = match fobj {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
let prepared = match fobj {
Object::Array(items) => {
let mut prepared = Vec::with_capacity(items.len());
for f in items {
prepared.push(prepare_function_object(reader, f)?);
}
Object::Array(prepared)
}
other => prepare_function_object(reader, other)?,
};
d.set("Function", prepared);
}
out.set(name, Object::Dict(d));
}
}
Ok(Some(out))
}
fn resolve_pattern_resources(
reader: &mut DocumentReader<'_>,
resources: &Dict,
) -> Result<Option<Dict>, PdfError> {
let pat_obj = resources
.entries()
.iter()
.find(|(k, _)| k == "Pattern")
.map(|(_, v)| v.clone());
let pat_obj = match pat_obj {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(pat_dict) = pat_obj else {
return Ok(None);
};
let mut out = Dict::new();
for (name, value) in pat_dict.entries() {
let resolved = match value {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
let mut d = match resolved {
Object::Dict(d) => d,
Object::Stream(s) => s.dict,
_ => continue,
};
if let Some((_, sh)) = d
.entries()
.iter()
.find(|(k, _)| k == "Shading")
.map(|(k, v)| (k.clone(), v.clone()))
{
let sh = match sh {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
if let Object::Dict(mut shading) = sh {
if let Some((_, fobj)) = shading
.entries()
.iter()
.find(|(k, _)| k == "Function")
.map(|(k, v)| (k.clone(), v.clone()))
{
let fobj = match fobj {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
let prepared = match fobj {
Object::Array(items) => {
let mut prepared = Vec::with_capacity(items.len());
for f in items {
prepared.push(prepare_function_object(reader, f)?);
}
Object::Array(prepared)
}
other => prepare_function_object(reader, other)?,
};
shading.set("Function", prepared);
}
d.set("Shading", Object::Dict(shading));
}
}
out.set(name, Object::Dict(d));
}
Ok(Some(out))
}
fn resolve_tiling_patterns(
reader: &mut DocumentReader<'_>,
resources: &Dict,
depth: usize,
) -> Result<Option<BTreeMap<String, TilingPattern>>, PdfError> {
if depth >= MAX_XOBJECT_DEPTH {
return Ok(None);
}
let pat_obj = resources
.entries()
.iter()
.find(|(k, _)| k == "Pattern")
.map(|(_, v)| v.clone());
let pat_obj = match pat_obj {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(pat_dict) = pat_obj else {
return Ok(None);
};
let mut out: BTreeMap<String, TilingPattern> = BTreeMap::new();
for (name, value) in pat_dict.entries() {
let resolved = match value {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
let Object::Stream(stream) = resolved else {
continue;
};
if dict_int(&stream.dict, "PatternType") != Some(1) {
continue;
}
if let Some(tp) = resolve_one_tiling_pattern(reader, &stream, depth)? {
out.insert(name.clone(), tp);
}
}
if out.is_empty() {
Ok(None)
} else {
Ok(Some(out))
}
}
fn resolve_one_tiling_pattern(
reader: &mut DocumentReader<'_>,
stream: &Stream,
depth: usize,
) -> Result<Option<TilingPattern>, PdfError> {
let bbox = match read_rect(&stream.dict, "BBox") {
Some(r) => r,
None => return Ok(None),
};
let xstep = match dict_num(&stream.dict, "XStep") {
Some(v) if v.is_finite() && v != 0.0 => v,
_ => return Ok(None),
};
let ystep = match dict_num(&stream.dict, "YStep") {
Some(v) if v.is_finite() && v != 0.0 => v,
_ => return Ok(None),
};
let paint_type = dict_int(&stream.dict, "PaintType").unwrap_or(1);
let matrix = form_matrix(&stream.dict);
let content_bytes = match decode_stream(stream) {
Ok(b) => b,
Err(_) => return Ok(None),
};
let cell_resources = match stream.dict.entries().iter().find(|(k, _)| k == "Resources") {
Some((_, Object::Reference(id))) => match reader.resolve(*id)? {
Object::Dict(d) => Some(d),
_ => None,
},
Some((_, Object::Dict(d))) => Some(d.clone()),
_ => None,
};
let ext_gstate_dict = match cell_resources.as_ref() {
Some(r) => resolve_ext_gstate(reader, r)?,
None => None,
};
let fonts_dict = match cell_resources.as_ref() {
Some(r) => resolve_font_resources(reader, r)?,
None => None,
};
let shading_dict = match cell_resources.as_ref() {
Some(r) => resolve_shading_resources(reader, r)?,
None => None,
};
let color_space_dict = match cell_resources.as_ref() {
Some(r) => resolve_color_space_resources(reader, r)?,
None => None,
};
let properties_dict = match cell_resources.as_ref() {
Some(r) => resolve_properties_resources(reader, r)?,
None => None,
};
let mut seen = HashSet::new();
let nested_forms = match cell_resources.as_ref() {
Some(r) => resolve_xobject_forms(reader, r, depth + 1, &mut seen)?,
None => None,
};
let pattern_dict = match cell_resources.as_ref() {
Some(r) => resolve_pattern_resources(reader, r)?,
None => None,
};
let nested_tiling = match cell_resources.as_ref() {
Some(r) => resolve_tiling_patterns(reader, r, depth + 1)?,
None => None,
};
let parsed = parse_content_stream_full_with_tiling(
&content_bytes,
ext_gstate_dict.as_ref(),
fonts_dict.as_ref(),
shading_dict.as_ref(),
color_space_dict.as_ref(),
properties_dict.as_ref(),
nested_forms.as_ref(),
pattern_dict.as_ref(),
nested_tiling.as_ref(),
)?;
if parsed.root.children.is_empty() {
return Ok(None);
}
Ok(Some(TilingPattern {
cell: parsed.root,
bbox,
xstep,
ystep,
matrix,
paint_type,
}))
}
fn resolve_type3_fonts(
reader: &mut DocumentReader<'_>,
resources: &Dict,
depth: usize,
) -> Result<Option<BTreeMap<String, Type3Font>>, PdfError> {
if depth >= MAX_XOBJECT_DEPTH {
return Ok(None);
}
let font_obj = resources
.entries()
.iter()
.find(|(k, _)| k == "Font")
.map(|(_, v)| v.clone());
let font_obj = match font_obj {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(font_dict) = font_obj else {
return Ok(None);
};
let mut out: BTreeMap<String, Type3Font> = BTreeMap::new();
for (name, value) in font_dict.entries() {
let resolved = match value {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
let Object::Dict(fd) = resolved else {
continue;
};
if !matches!(
fd.entries().iter().find(|(k, _)| k == "Subtype"),
Some((_, Object::Name(s))) if s == "Type3"
) {
continue;
}
if let Some(font) = resolve_one_type3_font(reader, &fd, resources, depth)? {
out.insert(name.clone(), font);
}
}
if out.is_empty() {
Ok(None)
} else {
Ok(Some(out))
}
}
fn resolve_one_type3_font(
reader: &mut DocumentReader<'_>,
fd: &Dict,
enclosing_resources: &Dict,
depth: usize,
) -> Result<Option<Type3Font>, PdfError> {
let font_matrix = match fd.entries().iter().find(|(k, _)| k == "FontMatrix") {
Some((_, obj @ Object::Array(items))) if items.len() == 6 => array_matrix(obj),
_ => Transform2D {
a: 0.001,
b: 0.0,
c: 0.0,
d: 0.001,
e: 0.0,
f: 0.0,
},
};
let mut encoding: BTreeMap<u8, String> = BTreeMap::new();
let enc_obj = match fd.entries().iter().find(|(k, _)| k == "Encoding") {
Some((_, Object::Reference(id))) => Some(reader.resolve(*id)?),
Some((_, other)) => Some(other.clone()),
None => None,
};
if let Some(Object::Dict(enc)) = enc_obj {
let diffs = match enc.entries().iter().find(|(k, _)| k == "Differences") {
Some((_, Object::Reference(id))) => Some(reader.resolve(*id)?),
Some((_, other)) => Some(other.clone()),
None => None,
};
if let Some(arr) = diffs {
if let Ok(parsed) = crate::reader::encoding::parse_encoding_differences(&arr) {
for ov in parsed.overrides {
encoding.insert(ov.code, ov.glyph_name);
}
}
}
}
if encoding.is_empty() {
return Ok(None);
}
let glyph_resources = match fd.entries().iter().find(|(k, _)| k == "Resources") {
Some((_, Object::Reference(id))) => match reader.resolve(*id)? {
Object::Dict(d) => Some(d),
_ => Some(enclosing_resources.clone()),
},
Some((_, Object::Dict(d))) => Some(d.clone()),
_ => Some(enclosing_resources.clone()),
};
let charprocs_obj = match fd.entries().iter().find(|(k, _)| k == "CharProcs") {
Some((_, Object::Reference(id))) => reader.resolve(*id)?,
Some((_, other)) => other.clone(),
None => return Ok(None),
};
let Object::Dict(charprocs) = charprocs_obj else {
return Ok(None);
};
let mut glyphs: BTreeMap<String, Group> = BTreeMap::new();
let mut shape_only: BTreeSet<String> = BTreeSet::new();
let referenced: BTreeSet<&String> = encoding.values().collect();
for (glyph_name, value) in charprocs.entries() {
if !referenced.contains(glyph_name) {
continue;
}
let resolved = match value {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
let Object::Stream(stream) = resolved else {
continue;
};
let content_bytes = match decode_stream(&stream) {
Ok(b) => b,
Err(_) => continue,
};
if charproc_is_shape_only(&content_bytes) {
shape_only.insert(glyph_name.clone());
}
if let Some(group) =
parse_glyph_description(reader, &content_bytes, glyph_resources.as_ref(), depth)?
{
if !group.children.is_empty() {
glyphs.insert(glyph_name.clone(), group);
}
}
}
if glyphs.is_empty() {
return Ok(None);
}
Ok(Some(Type3Font {
font_matrix,
encoding,
glyphs,
shape_only,
}))
}
fn charproc_is_shape_only(bytes: &[u8]) -> bool {
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_whitespace()
|| c.is_ascii_digit()
|| c == b'+'
|| c == b'-'
|| c == b'.'
|| c == b'e'
|| c == b'E'
{
i += 1;
continue;
}
if bytes[i..].starts_with(b"d1") {
return true;
}
return false;
}
false
}
fn parse_glyph_description(
reader: &mut DocumentReader<'_>,
content_bytes: &[u8],
glyph_resources: Option<&Dict>,
depth: usize,
) -> Result<Option<Group>, PdfError> {
let ext_gstate_dict = match glyph_resources {
Some(r) => resolve_ext_gstate(reader, r)?,
None => None,
};
let fonts_dict = match glyph_resources {
Some(r) => resolve_font_resources(reader, r)?,
None => None,
};
let shading_dict = match glyph_resources {
Some(r) => resolve_shading_resources(reader, r)?,
None => None,
};
let color_space_dict = match glyph_resources {
Some(r) => resolve_color_space_resources(reader, r)?,
None => None,
};
let properties_dict = match glyph_resources {
Some(r) => resolve_properties_resources(reader, r)?,
None => None,
};
let mut seen = HashSet::new();
let nested_forms = match glyph_resources {
Some(r) => resolve_xobject_forms(reader, r, depth + 1, &mut seen)?,
None => None,
};
let pattern_dict = match glyph_resources {
Some(r) => resolve_pattern_resources(reader, r)?,
None => None,
};
let tiling_patterns = match glyph_resources {
Some(r) => resolve_tiling_patterns(reader, r, depth + 1)?,
None => None,
};
let nested_type3 = match glyph_resources {
Some(r) => resolve_type3_fonts(reader, r, depth + 1)?,
None => None,
};
let parsed = parse_content_stream_full_with_type3(
content_bytes,
ext_gstate_dict.as_ref(),
fonts_dict.as_ref(),
shading_dict.as_ref(),
color_space_dict.as_ref(),
properties_dict.as_ref(),
nested_forms.as_ref(),
pattern_dict.as_ref(),
tiling_patterns.as_ref(),
nested_type3.as_ref(),
)?;
if parsed.root.children.is_empty() {
return Ok(None);
}
Ok(Some(parsed.root))
}
fn array_matrix(obj: &Object) -> Transform2D {
let Object::Array(items) = obj else {
return Transform2D::identity();
};
let mut m = [0.0f32; 6];
for (i, slot) in m.iter_mut().enumerate() {
match items.get(i) {
Some(Object::Integer(v)) => *slot = *v as f32,
Some(Object::Real(v)) => *slot = *v as f32,
_ => return Transform2D::identity(),
}
}
Transform2D {
a: m[0],
b: m[1],
c: m[2],
d: m[3],
e: m[4],
f: m[5],
}
}
fn dict_num(dict: &Dict, key: &str) -> Option<f32> {
match dict
.entries()
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
{
Some(Object::Integer(v)) => Some(*v as f32),
Some(Object::Real(v)) => Some(*v as f32),
_ => None,
}
}
fn dict_int(dict: &Dict, key: &str) -> Option<i64> {
match dict
.entries()
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
{
Some(Object::Integer(v)) => Some(*v),
_ => None,
}
}
fn read_rect(dict: &Dict, key: &str) -> Option<[f32; 4]> {
let items = match dict
.entries()
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
{
Some(Object::Array(items)) if items.len() == 4 => items,
_ => return None,
};
let mut out = [0.0f32; 4];
for (i, slot) in out.iter_mut().enumerate() {
*slot = match &items[i] {
Object::Integer(v) => *v as f32,
Object::Real(v) => *v as f32,
_ => return None,
};
if !slot.is_finite() {
return None;
}
}
Some(out)
}
fn resolve_color_space_resources(
reader: &mut DocumentReader<'_>,
resources: &Dict,
) -> Result<Option<Dict>, PdfError> {
let cs_obj = resources
.entries()
.iter()
.find(|(k, _)| k == "ColorSpace")
.map(|(_, v)| v.clone());
let cs_obj = match cs_obj {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(cs_dict) = cs_obj else {
return Ok(None);
};
let mut out = Dict::new();
for (name, value) in cs_dict.entries() {
let resolved = match value {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
let prepared = prepare_color_space_object(reader, resolved)?;
out.set(name, prepared);
}
Ok(Some(out))
}
fn resolve_properties_resources(
reader: &mut DocumentReader<'_>,
resources: &Dict,
) -> Result<Option<Dict>, PdfError> {
let props_obj = resources
.entries()
.iter()
.find(|(k, _)| k == "Properties")
.map(|(_, v)| v.clone());
let props_obj = match props_obj {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(props_dict) = props_obj else {
return Ok(None);
};
let mut out = Dict::new();
for (name, value) in props_dict.entries() {
let resolved = match value {
Object::Reference(id) => reader.resolve(*id)?,
other => other.clone(),
};
let d = match resolved {
Object::Dict(d) => Some(d),
Object::Stream(s) => Some(s.dict),
_ => None,
};
if let Some(d) = d {
out.set(name, Object::Dict(d));
}
}
Ok(Some(out))
}
const MAX_XOBJECT_DEPTH: usize = 12;
fn resolve_xobject_forms(
reader: &mut DocumentReader<'_>,
resources: &Dict,
depth: usize,
visited: &mut HashSet<ObjectId>,
) -> Result<Option<BTreeMap<String, Group>>, PdfError> {
if depth >= MAX_XOBJECT_DEPTH {
return Ok(None);
}
let xobj_obj = resources
.entries()
.iter()
.find(|(k, _)| k == "XObject")
.map(|(_, v)| v.clone());
let xobj_obj = match xobj_obj {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(xobj_dict) = xobj_obj else {
return Ok(None);
};
let mut out: BTreeMap<String, Group> = BTreeMap::new();
for (name, value) in xobj_dict.entries() {
let (form_id, resolved) = match value {
Object::Reference(id) => (Some(*id), reader.resolve(*id)?),
other => (None, other.clone()),
};
let Object::Stream(stream) = resolved else {
continue;
};
let subtype = stream
.dict
.entries()
.iter()
.find(|(k, _)| k == "Subtype")
.map(|(_, v)| v);
if !matches!(subtype, Some(Object::Name(s)) if s == "Form") {
continue;
}
if let Some(id) = form_id {
if visited.contains(&id) {
continue;
}
visited.insert(id);
}
let group = resolve_one_form_xobject(reader, &stream, depth, visited)?;
if let Some(id) = form_id {
visited.remove(&id);
}
if let Some(g) = group {
if !g.children.is_empty() {
out.insert(name.clone(), g);
}
}
}
if out.is_empty() {
Ok(None)
} else {
Ok(Some(out))
}
}
fn resolve_one_form_xobject(
reader: &mut DocumentReader<'_>,
stream: &Stream,
depth: usize,
visited: &mut HashSet<ObjectId>,
) -> Result<Option<Group>, PdfError> {
let content_bytes = match decode_stream(stream) {
Ok(b) => b,
Err(_) => return Ok(None),
};
let form_resources = match stream.dict.entries().iter().find(|(k, _)| k == "Resources") {
Some((_, Object::Reference(id))) => match reader.resolve(*id)? {
Object::Dict(d) => Some(d),
_ => None,
},
Some((_, Object::Dict(d))) => Some(d.clone()),
_ => None,
};
let ext_gstate_dict = match form_resources.as_ref() {
Some(r) => resolve_ext_gstate(reader, r)?,
None => None,
};
let fonts_dict = match form_resources.as_ref() {
Some(r) => resolve_font_resources(reader, r)?,
None => None,
};
let shading_dict = match form_resources.as_ref() {
Some(r) => resolve_shading_resources(reader, r)?,
None => None,
};
let color_space_dict = match form_resources.as_ref() {
Some(r) => resolve_color_space_resources(reader, r)?,
None => None,
};
let properties_dict = match form_resources.as_ref() {
Some(r) => resolve_properties_resources(reader, r)?,
None => None,
};
let nested_forms = match form_resources.as_ref() {
Some(r) => resolve_xobject_forms(reader, r, depth + 1, visited)?,
None => None,
};
let pattern_dict = match form_resources.as_ref() {
Some(r) => resolve_pattern_resources(reader, r)?,
None => None,
};
let tiling_patterns = match form_resources.as_ref() {
Some(r) => resolve_tiling_patterns(reader, r, depth + 1)?,
None => None,
};
let type3_fonts = match form_resources.as_ref() {
Some(r) => resolve_type3_fonts(reader, r, depth + 1)?,
None => None,
};
let parsed = parse_content_stream_full_with_type3(
&content_bytes,
ext_gstate_dict.as_ref(),
fonts_dict.as_ref(),
shading_dict.as_ref(),
color_space_dict.as_ref(),
properties_dict.as_ref(),
nested_forms.as_ref(),
pattern_dict.as_ref(),
tiling_patterns.as_ref(),
type3_fonts.as_ref(),
)?;
let inner = parsed.root;
let matrix = form_matrix(&stream.dict);
let clip = form_bbox_clip(&stream.dict);
let children = if inner.transform == Transform2D::identity()
&& inner.clip.is_none()
&& inner.opacity == 1.0
{
inner.children
} else {
vec![Node::Group(inner)]
};
if children.is_empty() {
return Ok(None);
}
Ok(Some(Group {
transform: matrix,
opacity: 1.0,
clip,
children,
..Group::default()
}))
}
fn form_matrix(dict: &Dict) -> Transform2D {
let nums = match dict.entries().iter().find(|(k, _)| k == "Matrix") {
Some((_, Object::Array(items))) if items.len() == 6 => items,
_ => return Transform2D::identity(),
};
let mut m = [0.0f32; 6];
for (i, slot) in m.iter_mut().enumerate() {
match &nums[i] {
Object::Integer(v) => *slot = *v as f32,
Object::Real(v) => *slot = *v as f32,
_ => return Transform2D::identity(),
}
}
Transform2D {
a: m[0],
b: m[1],
c: m[2],
d: m[3],
e: m[4],
f: m[5],
}
}
fn form_bbox_clip(dict: &Dict) -> Option<Path> {
let items = match dict.entries().iter().find(|(k, _)| k == "BBox") {
Some((_, Object::Array(items))) if items.len() == 4 => items,
_ => return None,
};
let mut v = [0.0f32; 4];
for (i, slot) in v.iter_mut().enumerate() {
match &items[i] {
Object::Integer(n) => *slot = *n as f32,
Object::Real(n) => *slot = *n as f32,
_ => return None,
}
}
let (x0, y0, x1, y1) = (v[0], v[1], v[2], v[3]);
let (lx, rx) = (x0.min(x1), x0.max(x1));
let (by, ty) = (y0.min(y1), y0.max(y1));
if rx <= lx || ty <= by {
return None;
}
let mut path = Path::new();
path.commands.push(PathCommand::MoveTo(Point::new(lx, by)));
path.commands.push(PathCommand::LineTo(Point::new(rx, by)));
path.commands.push(PathCommand::LineTo(Point::new(rx, ty)));
path.commands.push(PathCommand::LineTo(Point::new(lx, ty)));
path.commands.push(PathCommand::Close);
Some(path)
}
fn dict_rect4(dict: &Dict, key: &str) -> Option<[f32; 4]> {
let items = match dict.entries().iter().find(|(k, _)| k == key) {
Some((_, Object::Array(items))) if items.len() == 4 => items,
_ => return None,
};
let mut v = [0.0f32; 4];
for (i, slot) in v.iter_mut().enumerate() {
match &items[i] {
Object::Integer(n) => *slot = *n as f32,
Object::Real(n) => *slot = *n as f32,
_ => return None,
}
}
Some([
v[0].min(v[2]),
v[1].min(v[3]),
v[0].max(v[2]),
v[1].max(v[3]),
])
}
fn resolve_annotation_appearances(
reader: &mut DocumentReader<'_>,
page_dict: &Dict,
optional_content: Option<&crate::reader::ocg::OptionalContent>,
) -> Result<Vec<Group>, PdfError> {
let annots = match page_dict
.entries()
.iter()
.find(|(k, _)| k == "Annots")
.map(|(_, v)| v.clone())
{
Some(Object::Reference(id)) => match reader.resolve(id) {
Ok(o) => o,
Err(_) => return Ok(Vec::new()),
},
Some(other) => other,
None => return Ok(Vec::new()),
};
let Object::Array(items) = annots else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for item in items {
let annot = match item {
Object::Reference(id) => match reader.resolve(id) {
Ok(o) => o,
Err(_) => continue,
},
other => other,
};
let Object::Dict(annot) = annot else {
continue;
};
if !annotation_oc_visible(reader, &annot, optional_content) {
continue;
}
if let Some(group) = annotation_appearance_group(reader, &annot)? {
out.push(group);
}
}
Ok(out)
}
fn annotation_oc_visible(
reader: &mut DocumentReader<'_>,
annot: &Dict,
optional_content: Option<&crate::reader::ocg::OptionalContent>,
) -> bool {
let Some(entry) = annot
.entries()
.iter()
.find(|(k, _)| k == "OC")
.map(|(_, v)| v.clone())
else {
return true;
};
let Some(oc) = optional_content else {
return true;
};
let (group_id, dict) = match entry {
Object::Reference(id) => match reader.resolve(id) {
Ok(Object::Dict(d)) => (Some(id), d),
_ => return true,
},
Object::Dict(d) => (None, d),
_ => return true,
};
let type_name = dict.entries().iter().find_map(|(k, v)| match (k, v) {
(k, Object::Name(n)) if k == "Type" => Some(n.clone()),
_ => None,
});
let is_ocmd = type_name.as_deref() == Some("OCMD")
|| (type_name.is_none() && dict.entries().iter().any(|(k, _)| k == "OCGs" || k == "VE"));
if is_ocmd {
match crate::reader::ocg::parse_membership(reader, &dict) {
Ok(Some(mem)) => oc.evaluate_membership(&mem),
_ => true,
}
} else {
match group_id {
Some(id) => oc.is_visible(id),
None => true,
}
}
}
fn annotation_appearance_group(
reader: &mut DocumentReader<'_>,
annot: &Dict,
) -> Result<Option<Group>, PdfError> {
let flags = match annot.entries().iter().find(|(k, _)| k == "F") {
Some((_, Object::Integer(v))) => *v,
_ => 0,
};
const FLAG_HIDDEN: i64 = 1 << 1; const FLAG_NO_VIEW: i64 = 1 << 5; if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 {
return Ok(None);
}
if matches!(
annot.entries().iter().find(|(k, _)| k == "Subtype"),
Some((_, Object::Name(s))) if s == "Popup"
) {
return Ok(None);
}
let Some(rect) = dict_rect4(annot, "Rect") else {
return Ok(None);
};
let ap = match annot
.entries()
.iter()
.find(|(k, _)| k == "AP")
.map(|(_, v)| v.clone())
{
Some(Object::Reference(id)) => match reader.resolve(id) {
Ok(o) => o,
Err(_) => return Ok(None),
},
Some(other) => other,
None => return Ok(None),
};
let Object::Dict(ap) = ap else {
return Ok(None);
};
let n_entry = ap
.entries()
.iter()
.find(|(k, _)| k == "N")
.map(|(_, v)| v.clone());
let (stream_id, n_obj) = match n_entry {
Some(Object::Reference(id)) => match reader.resolve(id) {
Ok(o) => (Some(id), o),
Err(_) => return Ok(None),
},
Some(other) => (None, other),
None => return Ok(None),
};
let (stream_id, stream) = match n_obj {
Object::Stream(s) => (stream_id, s),
Object::Dict(states) => {
let Some(Object::Name(state)) = annot
.entries()
.iter()
.find(|(k, _)| k == "AS")
.map(|(_, v)| v.clone())
else {
return Ok(None);
};
let selected = states
.entries()
.iter()
.find(|(k, _)| *k == state)
.map(|(_, v)| v.clone());
match selected {
Some(Object::Reference(id)) => match reader.resolve(id) {
Ok(Object::Stream(s)) => (Some(id), s),
_ => return Ok(None),
},
Some(Object::Stream(s)) => (None, s),
_ => return Ok(None),
}
}
_ => return Ok(None),
};
build_appearance_group(reader, &stream, stream_id, rect)
}
fn build_appearance_group(
reader: &mut DocumentReader<'_>,
stream: &Stream,
stream_id: Option<ObjectId>,
rect: [f32; 4],
) -> Result<Option<Group>, PdfError> {
let Some(bbox) = dict_rect4(&stream.dict, "BBox") else {
return Ok(None);
};
let matrix = form_matrix(&stream.dict);
let mut visited = HashSet::new();
if let Some(id) = stream_id {
visited.insert(id);
}
let Some(form_group) = resolve_one_form_xobject(reader, stream, 0, &mut visited)? else {
return Ok(None);
};
let corners = [
Point::new(bbox[0], bbox[1]),
Point::new(bbox[2], bbox[1]),
Point::new(bbox[2], bbox[3]),
Point::new(bbox[0], bbox[3]),
];
let (mut tx0, mut ty0, mut tx1, mut ty1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for c in corners {
let p = matrix.apply(c);
tx0 = tx0.min(p.x);
ty0 = ty0.min(p.y);
tx1 = tx1.max(p.x);
ty1 = ty1.max(p.y);
}
let (tw, th) = (tx1 - tx0, ty1 - ty0);
let sx = if tw.is_finite() && tw > f32::EPSILON {
(rect[2] - rect[0]) / tw
} else {
1.0
};
let sy = if th.is_finite() && th > f32::EPSILON {
(rect[3] - rect[1]) / th
} else {
1.0
};
let a = Transform2D {
a: sx,
b: 0.0,
c: 0.0,
d: sy,
e: rect[0] - sx * tx0,
f: rect[1] - sy * ty0,
};
Ok(Some(Group {
transform: a,
children: vec![Node::Group(form_group)],
..Group::default()
}))
}
fn prepare_color_space_object(
reader: &mut DocumentReader<'_>,
obj: Object,
) -> Result<Object, PdfError> {
match obj {
Object::Reference(id) => {
let target = reader.resolve(id)?;
prepare_color_space_object(reader, target)
}
Object::Array(items) => {
let family = items.first().and_then(|o| match o {
Object::Name(n) => Some(n.clone()),
_ => None,
});
match family.as_deref() {
Some("ICCBased") => {
let mut out = vec![Object::Name("ICCBased".into())];
let stream_obj = match items.into_iter().nth(1) {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(Object::Array(out)),
};
match stream_obj {
Object::Stream(s) => {
let mut dict = s.dict;
if let Some((_, alt)) = dict
.entries()
.iter()
.find(|(k, _)| k == "Alternate")
.map(|(k, v)| (k.clone(), v.clone()))
{
let prepared_alt = prepare_color_space_object(reader, alt)?;
dict.set("Alternate", prepared_alt);
}
out.push(Object::Dict(dict));
}
Object::Dict(d) => out.push(Object::Dict(d)),
_ => {}
}
Ok(Object::Array(out))
}
Some("Indexed") => {
let mut it = items.into_iter();
let _family = it.next();
let base = match it.next() {
Some(b) => prepare_color_space_object(reader, b)?,
None => return Ok(Object::Array(vec![Object::Name("Indexed".into())])),
};
let hival = it.next().unwrap_or(Object::Null);
let hival = match hival {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
let lookup = match it.next() {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => Object::Null,
};
let lookup = match lookup {
Object::Stream(s) => Object::HexString(decode_stream(&s)?),
other => other,
};
Ok(Object::Array(vec![
Object::Name("Indexed".into()),
base,
hival,
lookup,
]))
}
Some("Separation") => {
let mut it = items.into_iter();
let _family = it.next();
let name = match it.next() {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(Object::Array(vec![Object::Name("Separation".into())])),
};
let alt = match it.next() {
Some(a) => prepare_color_space_object(reader, a)?,
None => Object::Null,
};
let tint = match it.next() {
Some(f) => prepare_function_object(reader, f)?,
None => Object::Null,
};
Ok(Object::Array(vec![
Object::Name("Separation".into()),
name,
alt,
tint,
]))
}
Some("DeviceN") => {
let mut it = items.into_iter();
let _family = it.next();
let names = match it.next() {
Some(Object::Reference(id)) => reader.resolve(id)?,
Some(other) => other,
None => return Ok(Object::Array(vec![Object::Name("DeviceN".into())])),
};
let names = match names {
Object::Array(elems) => {
let mut resolved = Vec::with_capacity(elems.len());
for e in elems {
let r = match e {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
resolved.push(r);
}
Object::Array(resolved)
}
other => other,
};
let alt = match it.next() {
Some(a) => prepare_color_space_object(reader, a)?,
None => Object::Null,
};
let tint = match it.next() {
Some(f) => prepare_function_object(reader, f)?,
None => Object::Null,
};
Ok(Object::Array(vec![
Object::Name("DeviceN".into()),
names,
alt,
tint,
]))
}
_ => Ok(Object::Array(items)),
}
}
other => Ok(other),
}
}
fn prepare_function_object(
reader: &mut DocumentReader<'_>,
obj: Object,
) -> Result<Object, PdfError> {
let obj = match obj {
Object::Reference(id) => reader.resolve(id)?,
other => other,
};
let mut dict = match obj {
Object::Stream(s) => {
let function_type = s
.dict
.entries()
.iter()
.find(|(k, _)| k == "FunctionType")
.and_then(|(_, v)| match v {
Object::Integer(n) => Some(*n),
_ => None,
});
match function_type {
Some(0) => {
let samples = decode_stream(&s)?;
let mut d = s.dict;
d.set("__Samples", Object::HexString(samples));
d
}
Some(4) => {
let program = decode_stream(&s)?;
let mut d = s.dict;
d.set("__Program", Object::HexString(program));
d
}
_ => s.dict,
}
}
Object::Dict(d) => d,
other => return Ok(other),
};
if let Some((_, Object::Array(funcs))) = dict
.entries()
.iter()
.find(|(k, _)| k == "Functions")
.map(|(k, v)| (k.clone(), v.clone()))
{
let mut prepared = Vec::with_capacity(funcs.len());
for f in funcs {
prepared.push(prepare_function_object(reader, f)?);
}
dict.set("Functions", Object::Array(prepared));
}
Ok(Object::Dict(dict))
}
fn extract_stream_data(reader: &mut DocumentReader<'_>, id: ObjectId) -> Result<Vec<u8>, PdfError> {
let obj = reader.resolve(id)?;
let Object::Stream(s) = obj else {
return Err(PdfError::other(format!(
"PDF reader: object {id:?} expected to be a Stream (got {obj:?})"
)));
};
decode_stream(&s)
}
pub fn decode_stream(stream: &Stream) -> Result<Vec<u8>, PdfError> {
let filter = stream
.dict
.entries()
.iter()
.find(|(k, _)| k == "Filter")
.map(|(_, v)| v.clone());
let parms = stream
.dict
.entries()
.iter()
.find(|(k, _)| k == "DecodeParms" || k == "DP")
.map(|(_, v)| v.clone());
match filter {
None => Ok(stream.data.clone()),
Some(Object::Name(name)) => apply_filter(&name, &stream.data, parms_for_index(&parms, 0)),
Some(Object::Array(items)) => {
let mut data = stream.data.clone();
for (idx, item) in items.iter().enumerate() {
let Object::Name(name) = item else {
return Err(PdfError::other(format!(
"PDF reader: /Filter chain item must be a Name (got {item:?})"
)));
};
data = apply_filter(name, &data, parms_for_index(&parms, idx))?;
}
Ok(data)
}
Some(other) => Err(PdfError::other(format!(
"PDF reader: /Filter must be a Name or array of Names (got {other:?})"
))),
}
}
fn parms_for_index(parms: &Option<Object>, idx: usize) -> Option<&Dict> {
match parms {
Some(Object::Dict(d)) if idx == 0 => Some(d),
Some(Object::Array(items)) => match items.get(idx) {
Some(Object::Dict(d)) => Some(d),
_ => None,
},
_ => None,
}
}
fn apply_filter(name: &str, data: &[u8], parms: Option<&Dict>) -> Result<Vec<u8>, PdfError> {
use crate::reader::filters;
match name {
"FlateDecode" | "Fl" => apply_predictor_post(filters::flate_decompress(data)?, parms),
"LZWDecode" | "LZW" => {
let early = parms
.and_then(|d| d.entries().iter().find(|(k, _)| k == "EarlyChange"))
.and_then(|(_, v)| match v {
Object::Integer(n) => Some(*n != 0),
_ => None,
})
.unwrap_or(true);
apply_predictor_post(filters::lzw_decode_with_early_change(data, early)?, parms)
}
"ASCII85Decode" | "A85" => filters::ascii85_decode(data),
"ASCIIHexDecode" | "AHx" => filters::ascii_hex_decode(data),
"RunLengthDecode" | "RL" => filters::run_length_decode(data),
other => Err(PdfError::other(format!(
"PDF reader: filter `{other}` not yet supported by decode_stream \
(image codec filters DCT/JPX/JBIG2/CCITTFax route through the image walkers)"
))),
}
}
fn apply_predictor_post(data: Vec<u8>, parms: Option<&Dict>) -> Result<Vec<u8>, PdfError> {
use crate::reader::filters::{apply_predictor, PredictorParams};
let Some(parms) = parms else {
return Ok(data);
};
let int = |key: &str| -> Option<i64> {
parms
.entries()
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| match v {
Object::Integer(n) => Some(*n),
_ => None,
})
};
let predictor = int("Predictor").unwrap_or(1);
if predictor <= 1 {
return Ok(data);
}
let def = PredictorParams::default();
let params = PredictorParams {
predictor,
colors: int("Colors")
.map(|n| n.max(0) as usize)
.unwrap_or(def.colors),
bits_per_component: int("BitsPerComponent")
.map(|n| n.max(0) as usize)
.unwrap_or(def.bits_per_component),
columns: int("Columns")
.map(|n| n.max(0) as usize)
.unwrap_or(def.columns),
};
apply_predictor(&data, ¶ms)
}
fn decode_metadata(info: Object) -> Result<Metadata, PdfError> {
let Object::Dict(d) = info else {
return Err(PdfError::other(format!(
"PDF reader: /Info must be a dict (got {info:?})"
)));
};
let mut m = Metadata::default();
for (k, v) in d.entries() {
match k.as_str() {
"Title" => m.title = decode_text(v),
"Author" => m.author = decode_text(v),
"Subject" => m.subject = decode_text(v),
"Keywords" => {
if let Some(s) = decode_text(v) {
m.keywords = s.split(',').map(|p| p.trim().to_owned()).collect();
}
}
"Creator" => m.creator = decode_text(v),
"Producer" => m.producer = decode_text(v),
"CreationDate" => m.created_at = decode_text(v).map(pdf_date_to_iso8601),
"ModDate" => m.modified_at = decode_text(v).map(pdf_date_to_iso8601),
other => {
if let Some(s) = decode_text(v) {
m.custom.insert(other.to_owned(), s);
}
}
}
}
Ok(m)
}
pub fn pdf_date_to_iso8601(s: String) -> String {
let bytes = s.as_bytes();
if !bytes.starts_with(b"D:") {
return s;
}
let rest = &bytes[2..];
if rest.len() < 4 {
return s.clone();
}
let mut out = String::with_capacity(25);
let year = &rest[0..4.min(rest.len())];
out.push_str(&String::from_utf8_lossy(year));
if rest.len() >= 6 {
out.push('-');
out.push_str(&String::from_utf8_lossy(&rest[4..6]));
}
if rest.len() >= 8 {
out.push('-');
out.push_str(&String::from_utf8_lossy(&rest[6..8]));
}
if rest.len() >= 10 {
out.push('T');
out.push_str(&String::from_utf8_lossy(&rest[8..10]));
}
if rest.len() >= 12 {
out.push(':');
out.push_str(&String::from_utf8_lossy(&rest[10..12]));
}
if rest.len() >= 14 {
out.push(':');
out.push_str(&String::from_utf8_lossy(&rest[12..14]));
}
if rest.len() == 15 && rest[14] == b'Z' {
out.push('Z');
} else if rest.len() >= 17 && (rest[14] == b'+' || rest[14] == b'-') {
out.push(rest[14] as char);
out.push_str(&String::from_utf8_lossy(&rest[15..17]));
if rest.len() >= 20 && rest[17] == b'\'' {
out.push(':');
out.push_str(&String::from_utf8_lossy(&rest[18..20]));
}
}
out
}
fn decode_text(v: &Object) -> Option<String> {
match v {
Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
Object::HexString(b) => {
if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
let utf16: Vec<u16> = b[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
Some(String::from_utf16_lossy(&utf16))
} else {
Some(String::from_utf8_lossy(b).into_owned())
}
}
_ => None,
}
}
fn number_to_f32(o: &Object) -> Result<f32, PdfError> {
match o {
Object::Integer(n) => Ok(*n as f32),
Object::Real(f) => Ok(*f as f32),
other => Err(PdfError::other(format!(
"PDF reader: expected number, got {other:?}"
))),
}
}
#[allow(dead_code)]
fn empty_root() -> Group {
Group::default()
}
#[allow(dead_code)]
fn empty_path_node() -> PathNode {
PathNode {
path: Path {
commands: vec![PathCommand::Close],
},
fill: Some(Paint::Solid(Rgba::opaque(0, 0, 0))),
stroke: None,
fill_rule: FillRule::NonZero,
}
}
#[allow(dead_code)]
fn _node_imported(_: Node) {}
#[cfg(test)]
mod tests {
use super::*;
use crate::write_pdf_from_scene;
fn make_scene_with_one_red_rect() -> Scene {
use oxideav_core::vector::{
FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
};
let mut p = Path::new();
p.commands.push(PathCommand::MoveTo(Point::new(10.0, 10.0)));
p.commands.push(PathCommand::LineTo(Point::new(90.0, 10.0)));
p.commands.push(PathCommand::LineTo(Point::new(90.0, 90.0)));
p.commands.push(PathCommand::LineTo(Point::new(10.0, 90.0)));
p.commands.push(PathCommand::Close);
let frame = VectorFrame {
width: 100.0,
height: 100.0,
view_box: None,
root: Group {
children: vec![Node::Path(PathNode {
path: p,
fill: Some(Paint::Solid(Rgba::opaque(255, 0, 0))),
stroke: None,
fill_rule: FillRule::NonZero,
})],
..Group::default()
},
pts: None,
time_base: TimeBase::new(1, 1),
};
let mut page = Page::new(100.0, 100.0);
page.content = frame;
Scene {
pages: Some(vec![page]),
..Scene::default()
}
}
#[test]
fn read_pdf_to_scene_roundtrip_single_page() {
let scene = make_scene_with_one_red_rect();
let pdf = write_pdf_from_scene(&scene).unwrap();
let parsed = read_pdf_to_scene(&pdf).unwrap();
let pages = parsed.pages.expect("scene has pages");
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].width, 100.0);
assert_eq!(pages[0].height, 100.0);
let root = &pages[0].content.root;
let path_node = find_first_path(root).expect("at least one PathNode in the tree");
match &path_node.fill {
Some(Paint::Solid(rgba)) => assert_eq!((rgba.r, rgba.g, rgba.b), (255, 0, 0)),
other => panic!("expected solid red, got {other:?}"),
}
}
fn find_first_path(group: &Group) -> Option<&PathNode> {
for child in &group.children {
match child {
Node::Path(p) => return Some(p),
Node::Group(g) => {
if let Some(p) = find_first_path(g) {
return Some(p);
}
}
_ => {}
}
}
None
}
#[test]
fn read_pdf_to_scene_roundtrip_multi_page() {
use oxideav_core::vector::Rgba;
let mut scene = make_scene_with_one_red_rect();
let mut p2 = Page::new(200.0, 100.0);
p2.content.width = 200.0;
p2.content.height = 100.0;
use oxideav_core::vector::{Group, Node, Paint, Path, PathCommand, PathNode, Point};
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(50.0, 50.0)));
path.commands.push(PathCommand::Close);
p2.content.root = Group {
children: vec![Node::Path(PathNode {
path,
fill: Some(Paint::Solid(Rgba::opaque(0, 255, 0))),
stroke: None,
fill_rule: FillRule::NonZero,
})],
..Group::default()
};
scene.pages.as_mut().unwrap().push(p2);
let pdf = write_pdf_from_scene(&scene).unwrap();
let parsed = read_pdf_to_scene(&pdf).unwrap();
let pages = parsed.pages.expect("scene has pages");
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].width, 100.0);
assert_eq!(pages[1].width, 200.0);
}
#[test]
fn read_pdf_metadata_roundtrip() {
let mut scene = make_scene_with_one_red_rect();
scene.metadata = Metadata {
title: Some("Round 3 Doc".into()),
author: Some("Mark".into()),
subject: Some("Reader test".into()),
keywords: vec!["pdf".into(), "round3".into()],
creator: Some("MyApp".into()),
producer: Some("oxideav-pdf".into()),
created_at: Some("2026-05-04T12:30:45Z".into()),
modified_at: Some("2026-05-04T13:00:00Z".into()),
..Metadata::default()
};
let pdf = write_pdf_from_scene(&scene).unwrap();
let parsed = read_pdf_to_scene(&pdf).unwrap();
assert_eq!(parsed.metadata.title.as_deref(), Some("Round 3 Doc"));
assert_eq!(parsed.metadata.author.as_deref(), Some("Mark"));
assert_eq!(parsed.metadata.subject.as_deref(), Some("Reader test"));
assert_eq!(parsed.metadata.creator.as_deref(), Some("MyApp"));
assert_eq!(parsed.metadata.producer.as_deref(), Some("oxideav-pdf"));
assert_eq!(
parsed.metadata.keywords,
vec!["pdf".to_string(), "round3".to_string()]
);
assert_eq!(
parsed.metadata.created_at.as_deref(),
Some("2026-05-04T12:30:45Z")
);
}
#[test]
fn read_pdf_custom_metadata_roundtrip() {
let mut scene = make_scene_with_one_red_rect();
let mut custom = std::collections::BTreeMap::new();
custom.insert("dc:rights".into(), "(c) 2026 Karpeles".into());
custom.insert("Trapped".into(), "False".into());
scene.metadata = Metadata {
custom,
..Metadata::default()
};
let pdf = write_pdf_from_scene(&scene).unwrap();
let parsed = read_pdf_to_scene(&pdf).unwrap();
assert_eq!(
parsed.metadata.custom.get("dc:rights").map(String::as_str),
Some("(c) 2026 Karpeles")
);
assert_eq!(
parsed.metadata.custom.get("Trapped").map(String::as_str),
Some("False")
);
}
#[test]
fn read_pdf_unicode_metadata_roundtrip() {
let mut scene = make_scene_with_one_red_rect();
scene.metadata = Metadata {
title: Some("日本語".into()),
..Metadata::default()
};
let pdf = write_pdf_from_scene(&scene).unwrap();
let parsed = read_pdf_to_scene(&pdf).unwrap();
assert_eq!(parsed.metadata.title.as_deref(), Some("日本語"));
}
#[test]
fn pdf_date_to_iso8601_format() {
assert_eq!(
pdf_date_to_iso8601("D:20260504123045Z".to_string()),
"2026-05-04T12:30:45Z"
);
assert_eq!(
pdf_date_to_iso8601("D:20260504123045+09'00'".to_string()),
"2026-05-04T12:30:45+09:00"
);
}
#[test]
fn no_metadata_yields_default() {
let scene = make_scene_with_one_red_rect();
let pdf = write_pdf_from_scene(&scene).unwrap();
let parsed = read_pdf_to_scene(&pdf).unwrap();
assert!(parsed.metadata.title.is_none());
assert!(parsed.metadata.custom.is_empty());
}
#[test]
fn decode_stream_applies_flate_png_predictor() {
let predicted: &[u8] = &[
0, 10, 20, 30, 2, 1, 2, 3, ];
let compressed = crate::zlib::flate_compress(predicted);
let dict = Dict::new()
.with("Filter", Object::Name("FlateDecode".into()))
.with(
"DecodeParms",
Object::Dict(
Dict::new()
.with("Predictor", Object::Integer(12))
.with("Columns", Object::Integer(3)),
),
);
let stream = Stream::new(dict, compressed);
let out = decode_stream(&stream).unwrap();
assert_eq!(out, [10u8, 20, 30, 11, 22, 33]);
}
#[test]
fn decode_stream_flate_without_predictor_is_passthrough() {
let raw = b"hello predictor-free world";
let compressed = crate::zlib::flate_compress(raw);
let dict = Dict::new().with("Filter", Object::Name("FlateDecode".into()));
let stream = Stream::new(dict, compressed);
assert_eq!(decode_stream(&stream).unwrap(), raw);
}
#[test]
fn resolve_shading_folds_mesh_stream_body() {
use crate::reader::content::evaluate_mesh_shading_for_test;
let mut body: Vec<u8> = Vec::new();
for (x, y, r, g, b) in [
(0u8, 0u8, 255u8, 0u8, 0u8),
(255, 0, 0, 255, 0),
(0, 255, 0, 0, 255),
] {
body.extend_from_slice(&[0, x, y, r, g, b]); }
let compressed = crate::zlib::flate_compress(&body);
let mut out: Vec<u8> = Vec::new();
out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
let mut offs: Vec<usize> = vec![0];
offs.push(out.len());
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offs.push(out.len());
out.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
offs.push(out.len());
out.extend_from_slice(
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
/Resources << /Shading << /Sh1 5 0 R >> >> >>\nendobj\n",
);
offs.push(out.len());
out.extend_from_slice(b"4 0 obj\n<< >>\nendobj\n");
offs.push(out.len());
let header = format!(
"5 0 obj\n<< /ShadingType 4 /ColorSpace /DeviceRGB \
/BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 \
/Decode [0 1 0 1 0 1 0 1 0 1] /Filter /FlateDecode /Length {} >>\nstream\n",
compressed.len()
);
out.extend_from_slice(header.as_bytes());
out.extend_from_slice(&compressed);
out.extend_from_slice(b"\nendstream\nendobj\n");
let xref_off = out.len();
out.extend_from_slice(b"xref\n0 6\n");
out.extend_from_slice(b"0000000000 65535 f \n");
for &o in &offs[1..] {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n")
.as_bytes(),
);
let mut reader = DocumentReader::open(&out).expect("open");
let resources = Dict::new().with(
"Shading",
Object::Dict(Dict::new().with("Sh1", Object::Reference(ObjectId::new(5)))),
);
let resolved = resolve_shading_resources(&mut reader, &resources)
.expect("resolve")
.expect("some shading dict");
let sh1 = match resolved.entries().iter().find(|(k, _)| k == "Sh1") {
Some((_, Object::Dict(d))) => d.clone(),
_ => panic!("Sh1 not a dict"),
};
assert!(sh1.entries().iter().any(|(k, _)| k == "__MeshData"));
let mesh = evaluate_mesh_shading_for_test(&sh1).expect("mesh");
assert!(format!("{mesh:?}").contains("Triangles"));
}
}