use std::collections::HashMap;
use std::io::Read;
use oxideav_core::vector::{
FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Rgba, 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;
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>,
}
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(),
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(),
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(),
crypt,
})
}
pub fn xref(&self) -> &XrefTable {
&self.xref
}
pub fn is_encrypted(&self) -> bool {
self.crypt.is_some()
}
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 (parsed_id, mut body) = p.parse_indirect()?;
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> {
let container_id = ObjectId::new(obj_stream_num);
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:?})"
)))
}
};
if index_within_stream >= n {
return Err(PdfError::other(format!(
"PDF reader: ObjStm index {index_within_stream} out of range (N={n})"
)));
}
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 pairs: 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})"
)));
}
pairs.push((num as u32, o as usize));
}
let (header_num, body_off) = pairs[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 abs_off = first
.checked_add(body_off)
.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()
)));
}
let mut bp = Parser::new(&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 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 mut scene_pages = Vec::with_capacity(leaves.len());
for leaf_id in leaves {
scene_pages.push(decode_page(&mut reader, leaf_id)?);
}
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()
})
}
fn walk_pages_tree(
reader: &mut DocumentReader<'_>,
node_id: ObjectId,
out: &mut Vec<ObjectId>,
) -> Result<(), PdfError> {
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(reader, id, out)?;
}
}
Ok(())
}
_ => Err(PdfError::other(format!(
"PDF reader: pages-tree node {node_id:?} has unknown /Type {kind:?}"
))),
}
}
fn decode_page(reader: &mut DocumentReader<'_>, page_id: ObjectId) -> 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 = page_dict
.entries()
.iter()
.find(|(k, _)| k == "MediaBox")
.map(|(_, v)| v.clone());
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 root = parse_content_stream(&content_bytes)?;
let mut page = Page::new(width, height);
page.content = VectorFrame {
width,
height,
view_box: None,
root,
pts: None,
time_base: TimeBase::new(1, 1),
};
Ok(page)
}
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());
match filter {
None => Ok(stream.data.clone()),
Some(Object::Name(name)) if name == "FlateDecode" => flate_decompress(&stream.data),
Some(Object::Array(items)) => {
let mut data = stream.data.clone();
for item in items {
let Object::Name(name) = item else {
return Err(PdfError::other(format!(
"PDF reader: /Filter chain item must be a Name (got {item:?})"
)));
};
if name != "FlateDecode" {
return Err(PdfError::other(format!(
"PDF reader: filter `{name}` not yet supported (round-3 = FlateDecode only)"
)));
}
data = flate_decompress(&data)?;
}
Ok(data)
}
Some(Object::Name(name)) => Err(PdfError::other(format!(
"PDF reader: filter `{name}` not yet supported (round-3 = FlateDecode only)"
))),
Some(other) => Err(PdfError::other(format!(
"PDF reader: /Filter must be a Name or array of Names (got {other:?})"
))),
}
}
fn flate_decompress(input: &[u8]) -> Result<Vec<u8>, PdfError> {
use flate2::read::ZlibDecoder;
let mut out = Vec::new();
let mut dec = ZlibDecoder::new(input);
dec.read_to_end(&mut out)
.map_err(|e| PdfError::other(format!("PDF reader: FlateDecode failed: {e}")))?;
Ok(out)
}
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());
}
}