use crate::date::Date;
use crate::error::{Error, Result};
use crate::names::{names_with, NameTree};
use crate::object::{decode_text_string, Dict, ObjRef, Object};
use crate::source::AsyncObjectSource;
use crate::tree::resolved_dict;
#[derive(Debug, Clone, PartialEq, Default)]
pub struct FileSpec {
pub file: Option<Vec<u8>>,
pub unicode_file: Option<String>,
pub description: Option<String>,
pub file_system: Option<String>,
pub volatile: bool,
pub embedded: Option<ObjRef>,
}
impl FileSpec {
pub fn name(&self) -> String {
self.unicode_file
.clone()
.or_else(|| self.file.as_deref().map(decode_text_string))
.unwrap_or_default()
}
pub fn url(&self) -> Option<String> {
if self.file_system.as_deref() != Some("URL") {
return None;
}
self.file.as_deref().map(decode_text_string)
}
}
pub async fn file_spec_data_with<S: AsyncObjectSource>(
src: &S,
spec: &FileSpec,
) -> Result<Vec<u8>> {
let r = spec.embedded.ok_or(Error::MissingKey("EF"))?;
match src.get(r).await? {
Object::Stream(stream) => src.stream_data(&stream).await,
other => Err(Error::TypeMismatch {
expected: "stream",
found: crate::document::type_name(&other),
}),
}
}
pub fn spec_components(spec: &[u8]) -> Vec<Vec<u8>> {
let mut components = vec![Vec::new()];
let mut bytes = spec.iter();
while let Some(&b) = bytes.next() {
match b {
b'\\' => {
if let Some(&escaped) = bytes.next() {
components.last_mut().expect("never empty").push(escaped);
}
}
b'/' => components.push(Vec::new()),
other => components.last_mut().expect("never empty").push(other),
}
}
components
}
pub async fn file_spec_with<S: AsyncObjectSource>(src: &S, object: &Object) -> Option<FileSpec> {
let dict = match src.resolve(object).await.ok()? {
Object::String(bytes) => {
return Some(FileSpec {
file: Some(bytes),
..FileSpec::default()
})
}
Object::Dict(dict) => dict,
_ => return None,
};
let embedded = match dict.get("EF") {
Some(ef) => match resolved_dict(src, ef).await {
Some(ef) => ["UF", "F", "DOS", "Mac", "Unix"]
.iter()
.find_map(|key| ef.get(key).and_then(Object::as_ref)),
None => None,
},
None => None,
};
Some(FileSpec {
file: bytes_entry(src, &dict, "F").await,
unicode_file: text_entry(src, &dict, "UF").await,
description: text_entry(src, &dict, "Desc").await,
file_system: match dict.get("FS") {
Some(fs) => src
.resolve(fs)
.await
.ok()
.and_then(|fs| fs.as_name().map(|n| n.0.clone())),
None => None,
},
volatile: match dict.get("V") {
Some(v) => src.resolve(v).await.ok().and_then(|v| v.as_bool()) == Some(true),
None => false,
},
embedded,
})
}
pub(crate) async fn bytes_entry<S: AsyncObjectSource>(
src: &S,
dict: &Dict,
key: &str,
) -> Option<Vec<u8>> {
let value = src.resolve(dict.get(key)?).await.ok()?;
value.as_str_bytes().map(<[u8]>::to_vec)
}
async fn text_entry<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<String> {
bytes_entry(src, dict, key)
.await
.map(|bytes| decode_text_string(&bytes))
}
async fn date_entry<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<Date> {
let bytes = bytes_entry(src, dict, key).await?;
Date::parse_pdf(std::str::from_utf8(&bytes).ok()?)
}
#[derive(Debug, Clone, PartialEq)]
pub struct EmbeddedFile {
pub name: String,
pub spec: FileSpec,
pub mime: Option<String>,
pub size: Option<u64>,
pub created: Option<Date>,
pub modified: Option<Date>,
pub checksum: Option<Vec<u8>>,
}
pub async fn embedded_files_with<S: AsyncObjectSource>(
src: &S,
trailer: &Dict,
) -> Vec<EmbeddedFile> {
let mut files = Vec::new();
for (key, value) in names_with(src, trailer, NameTree::EmbeddedFiles).await {
let Some(spec) = file_spec_with(src, &value).await else {
continue;
};
let stream = match spec.embedded {
Some(r) => match src.get(r).await {
Ok(Object::Stream(stream)) => Some(stream.dict),
_ => None,
},
None => None,
};
let params = match stream.as_ref().and_then(|s| s.get("Params")) {
Some(p) => resolved_dict(src, p).await,
None => None,
};
let params = params.unwrap_or_default();
files.push(EmbeddedFile {
name: decode_text_string(&key),
spec,
mime: match stream.as_ref().and_then(|s| s.get("Subtype")) {
Some(subtype) => src
.resolve(subtype)
.await
.ok()
.and_then(|s| s.as_name().map(|n| n.0.clone())),
None => None,
},
size: match params.get("Size") {
Some(size) => src
.resolve(size)
.await
.ok()
.and_then(|s| s.as_int())
.and_then(|s| u64::try_from(s).ok()),
None => None,
},
created: date_entry(src, ¶ms, "CreationDate").await,
modified: date_entry(src, ¶ms, "ModDate").await,
checksum: bytes_entry(src, ¶ms, "CheckSum").await,
});
}
files
}
pub async fn embedded_file_data_with<S: AsyncObjectSource>(
src: &S,
file: &EmbeddedFile,
) -> Result<Vec<u8>> {
file_spec_data_with(src, &file.spec).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Document;
use pdfboss_testkit::PdfBuilder;
fn doc(objects: &[(u32, &str)], streams: &[(u32, &str, &[u8])]) -> Document {
let mut b = PdfBuilder::new();
b.object(
1,
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 10 0 R >> >>",
);
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
for (num, body) in objects {
b.object(*num, body);
}
for (num, dict, data) in streams {
b.stream(*num, dict, data);
}
Document::load(b.build(1)).expect("load")
}
#[test]
fn url_specifications_name_a_url() {
let url = FileSpec {
file: Some(b"ftp://www.beatles.com/Movies/AbbeyRoad.mov".to_vec()),
file_system: Some("URL".to_string()),
..FileSpec::default()
};
assert_eq!(
url.url().as_deref(),
Some("ftp://www.beatles.com/Movies/AbbeyRoad.mov")
);
let plain = FileSpec {
file: Some(b"notes.txt".to_vec()),
..FileSpec::default()
};
assert_eq!(plain.url(), None);
let doc = doc(
&[(
10,
"<< /Names [(site) << /FS /URL /F (http://x.example/a) >>] >>",
)],
&[],
);
let files = doc.embedded_files();
assert_eq!(files[0].spec.url().as_deref(), Some("http://x.example/a"));
assert!(doc.embedded_file_data(&files[0]).is_err());
}
#[test]
fn embedded_files_come_from_the_name_tree() {
let doc = doc(
&[
(
10,
"<< /Names [(notes.txt) 11 0 R <FEFF00E90074> 13 0 R (external) 15 0 R (junk) 7] >>",
),
(
11,
"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /Desc <FEFF004E006F00740065> \
/EF << /F 12 0 R >> >>",
),
(
13,
"<< /Type /Filespec /F (data/e#t.csv) /UF <FEFF00E9002E006300730076> \
/EF << /UF 14 0 R /F 12 0 R >> /V true >>",
),
(15, "<< /Type /Filespec /FS /URL /F (http://example.com/x.pdf) >>"),
],
&[
(
12,
"/Type /EmbeddedFile /Subtype /text#2Fplain /Params << /Size 5 \
/ModDate (D:20260907120000Z) /CheckSum <5d41402abc4b2a76b9719d911017c592> >>",
b"hello",
),
(
14,
"/Type /EmbeddedFile /Subtype /text#2Fcsv /Params << /CreationDate (D:2025) >>",
b"a,b\n1,2\n",
),
],
);
let files = doc.embedded_files();
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
assert_eq!(
names,
["notes.txt", "ét", "external"],
"one per name, the number skipped"
);
let notes = &files[0];
assert_eq!(notes.spec.name(), "notes.txt");
assert_eq!(notes.spec.description.as_deref(), Some("Note"));
assert_eq!(notes.mime.as_deref(), Some("text/plain"));
assert_eq!(notes.size, Some(5));
assert_eq!(
notes.modified.map(|d| d.to_pdf_string()),
Some("D:20260907120000Z".to_string())
);
assert_eq!(
notes.checksum,
Some(vec![
0x5d, 0x41, 0x40, 0x2a, 0xbc, 0x4b, 0x2a, 0x76, 0xb9, 0x71, 0x9d, 0x91, 0x10, 0x17,
0xc5, 0x92
])
);
assert!(!notes.spec.volatile);
assert_eq!(doc.embedded_file_data(notes).unwrap(), b"hello");
let csv = &files[1];
assert_eq!(csv.spec.name(), "é.csv", "/UF wins over /F");
assert_eq!(csv.spec.file.as_deref(), Some(b"data/e#t.csv".as_slice()));
assert_eq!(
csv.spec.embedded,
Some(ObjRef { num: 14, gen: 0 }),
"/EF /UF wins"
);
assert_eq!(csv.mime.as_deref(), Some("text/csv"));
assert_eq!(csv.size, None);
assert_eq!(csv.created.map(|d| d.year), Some(2025));
assert!(csv.spec.volatile);
assert_eq!(doc.embedded_file_data(csv).unwrap(), b"a,b\n1,2\n");
let external = &files[2];
assert_eq!(external.spec.file_system.as_deref(), Some("URL"));
assert_eq!(external.spec.embedded, None);
assert_eq!(external.mime, None);
assert!(matches!(
doc.embedded_file_data(external),
Err(Error::MissingKey("EF"))
));
}
#[test]
fn file_specification_strings_split_on_unescaped_solidus() {
let parts = |s: &[u8]| -> Vec<String> {
spec_components(s)
.into_iter()
.map(|c| String::from_utf8(c).unwrap())
.collect()
};
assert_eq!(parts(b"in\\/out"), ["in/out"]);
assert_eq!(parts(b"ArtFiles/Figure1.pdf"), ["ArtFiles", "Figure1.pdf"]);
assert_eq!(
parts(b"/usr/local/x.pdf"),
["", "usr", "local", "x.pdf"],
"absolute"
);
assert_eq!(parts(b"a\\\\b"), ["a\\b"], "an escaped reverse solidus");
assert_eq!(parts(b"dir/"), ["dir", ""], "an empty last component");
assert_eq!(parts(b""), [""]);
}
#[test]
fn a_file_specification_names_its_file_from_uf_then_f_then_the_legacy_keys() {
let doc = doc(
&[
(10, "<< /Names [] >>"),
(20, "(readme.txt)"),
(21, "<< /Type /Filespec /F <FEFF00E9> >>"),
(
22,
"<< /Type /Filespec /DOS (X.EPS) /EF << /DOS 31 0 R >> >>",
),
(23, "42"),
],
&[],
);
let read = |num: u32| {
let obj = Object::Ref(ObjRef { num, gen: 0 });
crate::block_on(file_spec_with(&crate::Immediate(&doc), &obj))
};
let bare = read(20).unwrap();
assert_eq!(bare.file.as_deref(), Some(b"readme.txt".as_slice()));
assert_eq!(bare.name(), "readme.txt");
assert_eq!(bare.embedded, None);
let f_only = read(21).unwrap();
assert_eq!(f_only.name(), "é");
let legacy = read(22).unwrap();
assert_eq!(legacy.embedded, Some(ObjRef { num: 31, gen: 0 }));
assert_eq!(legacy.name(), "");
assert_eq!(read(23), None, "not a specification");
}
#[test]
fn documents_without_embedded_files_have_none() {
let mut b = PdfBuilder::new();
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
let plain = Document::load(b.build(1)).unwrap();
assert!(plain.embedded_files().is_empty());
}
}