use pdfrum_object::{Dict, Name, Object, Resolve, Stream, decode_text};
use crate::names;
#[derive(Debug, Clone, PartialEq)]
pub struct FileSpec {
pub object: Object,
}
const NAME_KEYS: [&Name; 5] = [names::UF, names::F, names::DOS, names::MAC, names::UNIX];
impl FileSpec {
#[must_use]
pub fn new(object: Object) -> FileSpec {
FileSpec { object }
}
#[must_use]
pub fn file_name<R: Resolve>(&self, r: &R) -> String {
match &self.object {
Object::Str(text) => decode_file_name(&latin1(&text.bytes)),
Object::Dict(dict) => decode_file_name(&dict_file_name(dict, r)),
_ => String::new(),
}
}
#[must_use]
pub fn file_stream<R: Resolve>(&self, r: &R) -> Option<Stream> {
let dict = self.object.as_dict()?;
let embedded = dict.dict(names::EF, r)?;
let keys = if is_url(dict, r) {
&NAME_KEYS[..2]
} else {
&NAME_KEYS[..]
};
for key in keys {
if dict.text(key, r).is_none_or(|text| text.is_empty()) {
continue;
}
if let Some(stream) = embedded.stream(key, r) {
return Some(stream);
}
}
None
}
#[must_use]
pub fn params<R: Resolve>(&self, r: &R) -> Option<Dict> {
self.file_stream(r)?.dict.dict(names::PARAMS, r)
}
}
fn is_url<R: Resolve>(dict: &Dict, r: &R) -> bool {
dict.byte_string(names::FS, r).as_deref() == Some(b"URL")
}
fn dict_file_name<R: Resolve>(dict: &Dict, r: &R) -> String {
let as_string = |key: &Name| {
dict.get(key, r)
.and_then(|v| v.get().as_string().map(|s| s.bytes.to_vec()))
};
let mut name = as_string(names::UF)
.map(|bytes| decode_text(&bytes).into_owned())
.unwrap_or_default();
if name.is_empty() {
name = as_string(names::F)
.map(|bytes| latin1(&bytes))
.unwrap_or_default();
}
if is_url(dict, r) {
return name;
}
if name.is_empty() {
for key in &NAME_KEYS[2..] {
if let Some(bytes) = as_string(key) {
name = latin1(&bytes);
break;
}
}
}
name
}
fn latin1(bytes: &[u8]) -> String {
bytes.iter().map(|b| char::from(*b)).collect()
}
#[must_use]
pub fn decode_file_name(name: &str) -> String {
name.to_owned()
}
#[must_use]
pub fn encode_file_name(name: &str) -> String {
name.to_owned()
}
#[cfg(test)]
mod tests {
use super::{FileSpec, decode_file_name, encode_file_name};
use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, Object, PdfString, Stream};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
fn spec(pairs: &[(&str, Object)]) -> FileSpec {
FileSpec::new(Object::Dict(dict(pairs)))
}
fn text(bytes: &[u8]) -> Object {
Object::Str(PdfString::literal(bytes))
}
#[test]
fn the_round_trip_is_the_identity_on_this_platform() {
for path in [
"./docs/test.pdf",
"../test_docs/test.pdf",
"/usr/local/home/test.pdf",
"",
"test.pdf",
] {
assert_eq!(decode_file_name(path), path);
assert_eq!(encode_file_name(path), path);
}
}
#[test]
fn a_bare_string_specification_reads_as_latin_one() {
let spec = FileSpec::new(text(b"caf\xE9.pdf"));
assert_eq!(spec.file_name(&NoResolve), "café.pdf");
}
#[test]
fn a_name_object_has_no_file_name_at_all() {
let spec = FileSpec::new(Object::Name(Name::from("test.pdf")));
assert_eq!(spec.file_name(&NoResolve), "");
}
#[test]
fn precedence_runs_unicode_then_file_then_the_three_platform_keys() {
let mut pairs = vec![("Unix", text(b"unix.pdf"))];
assert_eq!(spec(&pairs).file_name(&NoResolve), "unix.pdf");
pairs.insert(0, ("Mac", text(b"mac.pdf")));
assert_eq!(spec(&pairs).file_name(&NoResolve), "mac.pdf");
pairs.insert(0, ("DOS", text(b"dos.pdf")));
assert_eq!(spec(&pairs).file_name(&NoResolve), "dos.pdf");
pairs.insert(0, ("F", text(b"f.pdf")));
assert_eq!(spec(&pairs).file_name(&NoResolve), "f.pdf");
pairs.insert(0, ("UF", text(b"uf.pdf")));
assert_eq!(spec(&pairs).file_name(&NoResolve), "uf.pdf");
}
#[test]
fn a_name_valued_key_contributes_nothing() {
let evil = spec(&[
("UF", Object::Name(Name::from("http://evil.org"))),
("F", text(b"safe.pdf")),
]);
assert_eq!(evil.file_name(&NoResolve), "safe.pdf");
for key in ["Unix", "Mac", "DOS", "F", "UF"] {
let only = spec(&[(key, Object::Name(Name::from("x.pdf")))]);
assert_eq!(only.file_name(&NoResolve), "", "{key}");
}
}
#[test]
fn a_present_but_empty_platform_key_shadows_the_next_one() {
let shadowed = spec(&[("DOS", text(b"")), ("Mac", text(b"mac.pdf"))]);
assert_eq!(shadowed.file_name(&NoResolve), "");
}
#[test]
fn a_url_specification_still_reads_its_unicode_name() {
let url = spec(&[
("FS", Object::Name(Name::from("URL"))),
("UF", text(b"http://example.com/x.pdf")),
("DOS", text(b"ignored.pdf")),
]);
assert_eq!(url.file_name(&NoResolve), "http://example.com/x.pdf");
}
#[test]
fn a_stream_needs_both_a_name_and_a_matching_embedded_entry() {
let stream = Object::Stream(Box::new(Stream::new(
dict(&[("Params", Object::Dict(dict(&[("Size", Object::Int(6))])))]),
ByteSpan::from(b"hello!".to_vec()),
)));
let spec = spec(&[
("F", text(b"f.pdf")),
("Unix", text(b"unix.pdf")),
("EF", Object::Dict(dict(&[("Unix", stream)]))),
]);
assert!(spec.file_stream(&NoResolve).is_some());
assert_eq!(
spec.params(&NoResolve)
.and_then(|p| p.int(pdfrum_object::names::SIZE, &NoResolve)),
Some(6)
);
}
#[test]
fn no_embedded_files_dictionary_means_no_stream() {
assert!(FileSpec::new(text(b"x")).file_stream(&NoResolve).is_none());
assert!(spec(&[("F", text(b"f"))]).file_stream(&NoResolve).is_none());
assert!(
spec(&[("F", text(b"f")), ("EF", Object::Dict(Dict::new()))])
.file_stream(&NoResolve)
.is_none()
);
}
}