use crate::error::Result;
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct Metadata {
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Vec<String>,
pub producer: Option<String>,
pub creator: Option<String>,
pub creation_date: Option<String>,
pub modification_date: Option<String>,
}
#[derive(Default)]
struct PendingChanges {
set_title: Option<String>,
set_author: Option<String>,
set_subject: Option<String>,
set_keywords: Option<Vec<String>>,
}
pub struct MetadataMut<'a> {
doc: &'a mut crate::PdfDocument,
pending: PendingChanges,
}
impl<'a> MetadataMut<'a> {
pub(crate) fn new(doc: &'a mut crate::PdfDocument) -> Self {
Self {
doc,
pending: PendingChanges::default(),
}
}
pub fn set_title(&mut self, title: impl Into<String>) -> &mut Self {
self.pending.set_title = Some(title.into());
self
}
pub fn set_author(&mut self, author: impl Into<String>) -> &mut Self {
self.pending.set_author = Some(author.into());
self
}
pub fn set_subject(&mut self, subject: impl Into<String>) -> &mut Self {
self.pending.set_subject = Some(subject.into());
self
}
pub fn set_keywords(&mut self, keywords: &[&str]) -> &mut Self {
self.pending.set_keywords = Some(keywords.iter().map(|k| (*k).to_owned()).collect());
self
}
pub fn commit(&mut self) -> Result<()> {
let pending = std::mem::take(&mut self.pending);
flush_pending_to_lopdf(self.doc.lopdf_mut(), &pending)
}
}
impl Drop for MetadataMut<'_> {
fn drop(&mut self) {
let pending = std::mem::take(&mut self.pending);
if pending.has_any() {
let _ = flush_pending_to_lopdf(self.doc.lopdf_mut(), &pending);
}
}
}
impl PendingChanges {
fn has_any(&self) -> bool {
self.set_title.is_some()
|| self.set_author.is_some()
|| self.set_subject.is_some()
|| self.set_keywords.is_some()
}
}
fn flush_pending_to_lopdf(doc: &mut lopdf::Document, pending: &PendingChanges) -> Result<()> {
use lopdf::{Dictionary, Object};
if !pending.has_any() {
return Ok(());
}
let info_id = match doc.trailer.get(b"Info") {
Ok(Object::Reference(id)) => *id,
_ => {
let id = doc.add_object(Object::Dictionary(Dictionary::new()));
doc.trailer.set("Info", Object::Reference(id));
id
}
};
let info_obj = doc.objects.get_mut(&info_id).ok_or_else(|| {
crate::error::internal_error(format!(
"Info reference {info_id:?} in trailer does not resolve",
))
})?;
let info_dict = info_obj.as_dict_mut().map_err(|e| {
crate::error::internal_error(format!("Info object is not a dictionary: {e:?}"))
})?;
if let Some(title) = &pending.set_title {
info_dict.set("Title", Object::string_literal(title.as_str()));
}
if let Some(author) = &pending.set_author {
info_dict.set("Author", Object::string_literal(author.as_str()));
}
if let Some(subject) = &pending.set_subject {
info_dict.set("Subject", Object::string_literal(subject.as_str()));
}
if let Some(keywords) = &pending.set_keywords {
let joined = keywords.join(", ");
info_dict.set("Keywords", Object::string_literal(joined.as_str()));
}
Ok(())
}
pub(crate) fn read_info_dates(doc: &lopdf::Document) -> (Option<String>, Option<String>) {
fn read_string(doc: &lopdf::Document, dict: &lopdf::Dictionary, key: &[u8]) -> Option<String> {
let obj = dict.get(key).ok()?;
let resolved = match obj {
lopdf::Object::Reference(id) => doc.get_object(*id).ok()?,
other => other,
};
lopdf::decode_text_string(resolved).ok()
}
let info_dict = match doc.trailer.get(b"Info") {
Ok(lopdf::Object::Reference(id)) => match doc.get_object(*id).and_then(|o| o.as_dict()) {
Ok(d) => d,
Err(_) => return (None, None),
},
Ok(lopdf::Object::Dictionary(d)) => d,
_ => return (None, None),
};
let creation = read_string(doc, info_dict, b"CreationDate");
let modification = read_string(doc, info_dict, b"ModDate");
(creation, modification)
}
pub(crate) fn parse_keywords(raw: Option<String>) -> Vec<String> {
match raw {
None => Vec::new(),
Some(s) => s
.split(',')
.map(|k| k.trim().to_owned())
.filter(|k| !k.is_empty())
.collect(),
}
}
#[cfg(test)]
mod tests {
use lopdf::Object;
fn attach_invalid_info_object(doc: &mut crate::PdfDocument) {
let info_id = doc.lopdf_mut().add_object(Object::Integer(7));
doc.lopdf_mut()
.trailer
.set("Info", Object::Reference(info_id));
}
#[test]
fn commit_surfaces_flush_errors_that_drop_would_swallow() {
let mut explicit = crate::PdfDocument::create();
attach_invalid_info_object(&mut explicit);
let err = explicit
.metadata_mut()
.set_title("Broken title")
.commit()
.unwrap_err();
assert!(
matches!(err, crate::Error::Internal { .. }),
"expected explicit commit to surface the flush failure, got {err:?}",
);
let mut best_effort = crate::PdfDocument::create();
attach_invalid_info_object(&mut best_effort);
{
let mut metadata = best_effort.metadata_mut();
metadata.set_title("Dropped title");
}
let bytes = best_effort
.to_bytes()
.expect("drop path should swallow metadata flush errors");
let reopened = crate::PdfDocument::from_bytes(&bytes).expect("reparse after drop");
assert_eq!(
reopened.metadata().title.as_deref(),
None,
"drop auto-commit should remain best-effort when flushing metadata fails",
);
}
}