printwell-pdf 0.1.9

PDF manipulation features (forms, signing) for Printwell
Documentation
//! PDF bookmark/outline support.
//!
//! This module provides functionality for adding bookmarks (outlines)
//! to PDF documents using `PDFium` via FFI.
//!
//! # Example
//!
//! ```ignore
//! use printwell_pdf::bookmarks::{add_bookmarks, Bookmark};
//!
//! let bookmarks = vec![
//!     Bookmark::builder()
//!         .title("Chapter 1")
//!         .page(1)
//!         .build(),
//!     Bookmark::builder()
//!         .title("Section 1.1")
//!         .page(2)
//!         .parent(0)
//!         .build(),
//! ];
//!
//! let result = add_bookmarks(&pdf_data, &bookmarks)?;
//! ```

use crate::Result;
use typed_builder::TypedBuilder;

/// A bookmark entry
#[derive(Debug, Clone, TypedBuilder)]
pub struct Bookmark {
    /// Bookmark title (displayed in the outline)
    #[builder(setter(into))]
    pub title: String,

    /// Target page number (1-indexed)
    pub page: u32,

    /// Y position on the page (in PDF points from bottom, default: top of page)
    #[builder(default)]
    pub y_position: Option<f64>,

    /// Parent bookmark index (-1 for root level, 0+ for children)
    #[builder(default = -1)]
    pub parent_index: i32,

    /// Whether the bookmark is initially expanded (shows children)
    #[builder(default = true)]
    pub open: bool,

    /// Nesting level (0 = root, 1+ = nested)
    #[builder(default = 0)]
    pub level: u32,
}

impl Bookmark {
    /// Create a new root-level bookmark
    pub fn new(title: impl Into<String>, page: u32) -> Self {
        Self {
            title: title.into(),
            page,
            y_position: None,
            parent_index: -1,
            open: true,
            level: 0,
        }
    }

    /// Create a child bookmark
    pub fn child(title: impl Into<String>, page: u32, parent_index: i32) -> Self {
        Self {
            title: title.into(),
            page,
            y_position: None,
            parent_index,
            open: true,
            level: 1,
        }
    }
}

/// Information about a heading detected in the HTML
#[derive(Debug, Clone)]
pub struct HeadingInfo {
    /// Heading level (1-6 for h1-h6)
    pub level: u8,
    /// Heading text content
    pub text: String,
    /// Page number where the heading appears (1-indexed)
    pub page: u32,
    /// Y position on the page (in PDF points from bottom)
    pub y_position: f64,
}

/// Build a hierarchical bookmark tree from flat headings.
///
/// This function converts a flat list of headings (like h1, h2, h3)
/// into a properly nested bookmark structure.
///
/// # Arguments
/// * `headings` - List of detected headings in document order
///
/// # Returns
/// A list of bookmarks with proper parent indices set.
#[must_use]
pub fn build_bookmark_tree(headings: &[HeadingInfo]) -> Vec<Bookmark> {
    if headings.is_empty() {
        return Vec::new();
    }

    let mut bookmarks = Vec::with_capacity(headings.len());
    let mut parent_stack: Vec<(u8, i32)> = Vec::new(); // (level, index)

    for heading in headings {
        // Pop parents that are at the same level or deeper
        while let Some(&(parent_level, _)) = parent_stack.last() {
            if parent_level >= heading.level {
                parent_stack.pop();
            } else {
                break;
            }
        }

        // Determine parent index
        let parent_index = parent_stack.last().map_or(-1, |&(_, idx)| idx);

        let bookmark = Bookmark {
            title: heading.text.clone(),
            page: heading.page,
            y_position: Some(heading.y_position),
            parent_index,
            open: heading.level <= 2, // Expand h1 and h2 by default
            level: u32::from(heading.level - 1),
        };

        let current_index = i32::try_from(bookmarks.len()).unwrap_or(i32::MAX);
        bookmarks.push(bookmark);

        // Push this as a potential parent
        parent_stack.push((heading.level, current_index));
    }

    bookmarks
}

/// Add bookmarks to a PDF document.
///
/// This function adds a bookmark outline structure to an existing PDF.
///
/// # Arguments
/// * `pdf_data` - The input PDF data
/// * `bookmarks` - The bookmarks to add
///
/// # Returns
/// The modified PDF data with bookmarks.
///
/// # Errors
///
/// Returns an error if the bookmarks cannot be added to the PDF.
pub fn add_bookmarks(pdf_data: &[u8], bookmarks: &[Bookmark]) -> Result<Vec<u8>> {
    if bookmarks.is_empty() {
        return Ok(pdf_data.to_vec());
    }

    // Convert to FFI format
    let bookmark_defs: Vec<printwell_sys::BookmarkDef> = bookmarks
        .iter()
        .map(|b| printwell_sys::BookmarkDef {
            title: b.title.clone(),
            page: i32::try_from(b.page).unwrap_or(i32::MAX),
            y_position: b.y_position.unwrap_or(-1.0),
            parent_index: b.parent_index,
            open: b.open,
        })
        .collect();

    // Call FFI function
    let result = printwell_sys::ffi::pdf_add_bookmarks(pdf_data, &bookmark_defs)
        .map_err(|e| crate::BookmarkError::Operation(format!("Failed to add bookmarks: {e}")))?;

    Ok(result)
}

/// Extract existing bookmarks from a PDF document.
///
/// # Errors
///
/// Returns an error if the bookmarks cannot be extracted from the PDF.
pub fn extract_bookmarks(pdf_data: &[u8]) -> Result<Vec<Bookmark>> {
    let bookmark_defs = printwell_sys::ffi::pdf_get_bookmarks(pdf_data).map_err(|e| {
        crate::BookmarkError::Operation(format!("Failed to extract bookmarks: {e}"))
    })?;

    let bookmarks = bookmark_defs
        .iter()
        .map(|b| Bookmark {
            title: b.title.clone(),
            page: u32::try_from(b.page).unwrap_or(0),
            y_position: if b.y_position >= 0.0 {
                Some(b.y_position)
            } else {
                None
            },
            parent_index: b.parent_index,
            open: b.open,
            level: 0, // Level would need to be computed from parent chain
        })
        .collect();

    Ok(bookmarks)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_bookmark_tree() {
        let headings = vec![
            HeadingInfo {
                level: 1,
                text: "Chapter 1".into(),
                page: 1,
                y_position: 700.0,
            },
            HeadingInfo {
                level: 2,
                text: "Section 1.1".into(),
                page: 2,
                y_position: 750.0,
            },
            HeadingInfo {
                level: 2,
                text: "Section 1.2".into(),
                page: 3,
                y_position: 750.0,
            },
            HeadingInfo {
                level: 1,
                text: "Chapter 2".into(),
                page: 4,
                y_position: 700.0,
            },
            HeadingInfo {
                level: 2,
                text: "Section 2.1".into(),
                page: 5,
                y_position: 750.0,
            },
            HeadingInfo {
                level: 3,
                text: "Subsection 2.1.1".into(),
                page: 5,
                y_position: 600.0,
            },
        ];

        let bookmarks = build_bookmark_tree(&headings);

        assert_eq!(bookmarks.len(), 6);
        assert_eq!(bookmarks[0].parent_index, -1); // Chapter 1 is root
        assert_eq!(bookmarks[1].parent_index, 0); // Section 1.1 -> Chapter 1
        assert_eq!(bookmarks[2].parent_index, 0); // Section 1.2 -> Chapter 1
        assert_eq!(bookmarks[3].parent_index, -1); // Chapter 2 is root
        assert_eq!(bookmarks[4].parent_index, 3); // Section 2.1 -> Chapter 2
        assert_eq!(bookmarks[5].parent_index, 4); // Subsection 2.1.1 -> Section 2.1
    }
}