rpdfium-doc 7676.6.2

Document-level features for rpdfium
Documentation
//! Annotation appearance stream extraction (ISO 32000-2 section 12.5.5).
//!
//! Each annotation can have an `/AP` dictionary with up to three appearance
//! sub-dictionaries: `/N` (normal), `/R` (rollover), and `/D` (down).
//! Each sub-entry can be a single stream or a dictionary mapping state names
//! to streams.

use rpdfium_core::{Name, ObjectId, PdfSource};
use rpdfium_parser::{Object, ObjectStore};

use crate::error::{DocError, DocResult};

/// Resolved appearance stream object IDs for an annotation.
#[derive(Debug, Clone)]
pub struct AnnotationAppearance {
    /// Normal appearance stream (`/AP/N`).
    pub normal: Option<ObjectId>,
    /// Rollover appearance stream (`/AP/R`).
    pub rollover: Option<ObjectId>,
    /// Down (mouse-press) appearance stream (`/AP/D`).
    pub down: Option<ObjectId>,
}

/// Extract appearance stream references from an `/AP` dictionary.
///
/// Each sub-entry (`/N`, `/R`, `/D`) can be:
/// - An indirect reference to a stream (Form XObject) -- returns the ObjectId directly
/// - A dictionary mapping state names to streams -- returns the first entry's ObjectId
pub fn extract_appearance<S: PdfSource>(
    ap_obj: &Object,
    store: &ObjectStore<S>,
) -> DocResult<AnnotationAppearance> {
    let resolved = store
        .deep_resolve(ap_obj)
        .map_err(|e| DocError::Parser(e.to_string()))?;
    let ap_dict = resolved.as_dict().ok_or(DocError::UnexpectedType)?;

    let normal = extract_sub_appearance(ap_dict.get(&Name::n()), store);
    let rollover = extract_sub_appearance(ap_dict.get(&Name::r()), store);
    let down = extract_sub_appearance(ap_dict.get(&Name::d()), store);

    Ok(AnnotationAppearance {
        normal,
        rollover,
        down,
    })
}

/// Extract a single sub-appearance entry (e.g., `/N` value).
///
/// Returns the ObjectId of the stream if found, or the first entry from
/// a state dictionary.
fn extract_sub_appearance<S: PdfSource>(
    obj: Option<&Object>,
    store: &ObjectStore<S>,
) -> Option<ObjectId> {
    let entry = obj?;

    // If it's a direct reference, that's the stream ID
    if let Some(id) = entry.as_reference() {
        return Some(id);
    }

    // Try to resolve and check if it's a stream (which should have been a ref)
    let resolved = store.deep_resolve(entry).ok()?;

    // If it's a dict (state dictionary), take the first entry's value
    if let Some(state_dict) = resolved.as_dict() {
        for value in state_dict.values() {
            if let Some(id) = value.as_reference() {
                return Some(id);
            }
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use rpdfium_core::error::ObjectId as CoreObjectId;
    use std::collections::HashMap;

    fn build_store() -> ObjectStore<Vec<u8>> {
        let pdf = build_minimal_pdf();
        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
    }

    fn build_minimal_pdf() -> Vec<u8> {
        let mut pdf = Vec::new();
        pdf.extend_from_slice(b"%PDF-1.4\n");
        let obj1_offset = pdf.len();
        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
        let obj2_offset = pdf.len();
        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
        let xref_offset = pdf.len();
        pdf.extend_from_slice(b"xref\n0 3\n");
        pdf.extend_from_slice(b"0000000000 65535 f \r\n");
        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj1_offset).as_bytes());
        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj2_offset).as_bytes());
        pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
        pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref_offset).as_bytes());
        pdf
    }

    #[test]
    fn test_extract_appearance_with_direct_refs() {
        let store = build_store();
        let mut ap_dict = HashMap::new();
        ap_dict.insert(Name::n(), Object::Reference(CoreObjectId::new(1, 0)));
        ap_dict.insert(Name::r(), Object::Reference(CoreObjectId::new(2, 0)));

        let ap_obj = Object::Dictionary(ap_dict);
        let result = extract_appearance(&ap_obj, &store).unwrap();
        assert_eq!(result.normal, Some(CoreObjectId::new(1, 0)));
        assert_eq!(result.rollover, Some(CoreObjectId::new(2, 0)));
        assert!(result.down.is_none());
    }

    #[test]
    fn test_extract_appearance_with_state_dict() {
        let store = build_store();
        // /N is a state dict mapping state names to stream refs
        let mut state_dict = HashMap::new();
        state_dict.insert(
            Name::from("Yes"),
            Object::Reference(CoreObjectId::new(1, 0)),
        );

        let mut ap_dict = HashMap::new();
        ap_dict.insert(Name::n(), Object::Dictionary(state_dict));

        let ap_obj = Object::Dictionary(ap_dict);
        let result = extract_appearance(&ap_obj, &store).unwrap();
        // Should pick the first entry from the state dict
        assert!(result.normal.is_some());
    }

    #[test]
    fn test_extract_appearance_empty() {
        let store = build_store();
        let ap_dict: HashMap<Name, Object> = HashMap::new();
        let ap_obj = Object::Dictionary(ap_dict);
        let result = extract_appearance(&ap_obj, &store).unwrap();
        assert!(result.normal.is_none());
        assert!(result.rollover.is_none());
        assert!(result.down.is_none());
    }
}