printwell-cli 0.1.11

Command-line tool for HTML to PDF conversion
Documentation
//! Bookmarks command implementation.

use anyhow::{Context, Result};

use crate::cli::args::BookmarksArgs;
use crate::cli::utils::parse_colon_spec;

pub fn bookmarks(args: &BookmarksArgs) -> Result<()> {
    use printwell::bookmarks::add_bookmarks;

    let pdf_data = std::fs::read(&args.input)
        .with_context(|| format!("Failed to read input file: {}", args.input))?;

    // Extract bookmarks if requested
    if args.extract.is_some() || (args.add.is_empty() && args.from_json.is_none()) {
        return extract_bookmarks_cmd(&pdf_data, args);
    }

    // Add bookmarks
    let output_path = args
        .output
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("--output is required when adding bookmarks"))?;

    let mut bookmarks_to_add = Vec::new();

    // Parse --add arguments
    for spec in &args.add {
        let bm =
            parse_bookmark_spec(spec).with_context(|| format!("Invalid bookmark spec: {spec}"))?;
        bookmarks_to_add.push(bm);
    }

    // Load from JSON if specified
    if let Some(ref json_path) = args.from_json {
        let json_bookmarks = load_bookmarks_from_json(json_path)?;
        bookmarks_to_add.extend(json_bookmarks);
    }

    if bookmarks_to_add.is_empty() {
        anyhow::bail!("No bookmarks specified. Use --add or --from-json");
    }

    let result =
        add_bookmarks(&pdf_data, &bookmarks_to_add).context("Failed to add bookmarks to PDF")?;

    std::fs::write(output_path, &result)
        .with_context(|| format!("Failed to write output file: {output_path}"))?;

    eprintln!(
        "Added {} bookmarks, written to: {}",
        bookmarks_to_add.len(),
        output_path
    );

    Ok(())
}

fn extract_bookmarks_cmd(pdf_data: &[u8], args: &BookmarksArgs) -> Result<()> {
    use printwell::bookmarks::extract_bookmarks;

    let bookmarks = extract_bookmarks(pdf_data).context("Failed to extract bookmarks from PDF")?;

    if let Some(ref extract_path) = args.extract {
        // Write to file
        let json = serde_json::to_string_pretty(
            &bookmarks
                .iter()
                .map(|b| {
                    serde_json::json!({
                        "title": b.title,
                        "page": b.page,
                        "y_position": b.y_position,
                        "parent_index": b.parent_index,
                        "open": b.open,
                        "level": b.level,
                    })
                })
                .collect::<Vec<_>>(),
        )
        .context("Failed to serialize bookmarks")?;
        std::fs::write(extract_path, &json)
            .with_context(|| format!("Failed to write bookmarks to: {extract_path}"))?;
        eprintln!(
            "Extracted {} bookmarks to: {}",
            bookmarks.len(),
            extract_path
        );
    } else {
        // Print to stdout
        match args.format.as_str() {
            "json" => {
                let json = serde_json::to_string_pretty(
                    &bookmarks
                        .iter()
                        .map(|b| {
                            serde_json::json!({
                                "title": b.title,
                                "page": b.page,
                                "y_position": b.y_position,
                                "parent_index": b.parent_index,
                                "open": b.open,
                            })
                        })
                        .collect::<Vec<_>>(),
                )
                .context("Failed to serialize bookmarks")?;
                println!("{json}");
            }
            _ => {
                if bookmarks.is_empty() {
                    println!("No bookmarks found in document.");
                } else {
                    println!("Found {} bookmark(s):\n", bookmarks.len());
                    for (i, bm) in bookmarks.iter().enumerate() {
                        let indent = "  ".repeat(bm.level as usize);
                        println!(
                            "{}#{}: \"{}\" -> page {} (y: {:?})",
                            indent,
                            i + 1,
                            bm.title,
                            bm.page,
                            bm.y_position
                        );
                    }
                }
            }
        }
    }

    Ok(())
}

fn parse_bookmark_spec(spec: &str) -> Result<printwell::bookmarks::Bookmark> {
    let parts = parse_colon_spec(
        spec,
        2,
        "title:page or title:page:y_position or title:page:y_position:parent_index",
    )?;

    let title = parts[0].to_string();
    let page: u32 = parts[1]
        .parse()
        .with_context(|| format!("Invalid page number: {}", parts[1]))?;

    let y_position = if parts.len() > 2 && !parts[2].is_empty() {
        Some(
            parts[2]
                .parse::<f64>()
                .with_context(|| format!("Invalid y_position: {}", parts[2]))?,
        )
    } else {
        None
    };

    let parent_index = if parts.len() > 3 {
        parts[3]
            .parse::<i32>()
            .with_context(|| format!("Invalid parent_index: {}", parts[3]))?
    } else {
        -1
    };

    Ok(printwell::bookmarks::Bookmark {
        title,
        page,
        y_position,
        parent_index,
        open: true,
        level: 0,
    })
}

fn load_bookmarks_from_json(json_path: &str) -> Result<Vec<printwell::bookmarks::Bookmark>> {
    let json_content = std::fs::read_to_string(json_path)
        .with_context(|| format!("Failed to read bookmarks JSON: {json_path}"))?;

    let json_bookmarks: Vec<serde_json::Value> =
        serde_json::from_str(&json_content).context("Failed to parse bookmarks JSON")?;

    let mut bookmarks = Vec::new();
    for bm_json in json_bookmarks {
        let title = bm_json
            .get("title")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("Bookmark missing 'title' field"))?;
        let page = bm_json
            .get("page")
            .and_then(serde_json::Value::as_u64)
            .ok_or_else(|| anyhow::anyhow!("Bookmark missing 'page' field"))?;
        let page = u32::try_from(page).with_context(|| format!("Page number {page} too large"))?;
        let y_position = bm_json
            .get("y_position")
            .and_then(serde_json::Value::as_f64);
        let parent_index = bm_json
            .get("parent_index")
            .and_then(serde_json::Value::as_i64)
            .unwrap_or(-1);
        let parent_index = i32::try_from(parent_index)
            .with_context(|| format!("Parent index {parent_index} out of range"))?;
        let open = bm_json
            .get("open")
            .and_then(serde_json::Value::as_bool)
            .unwrap_or(true);

        bookmarks.push(printwell::bookmarks::Bookmark {
            title: title.to_string(),
            page,
            y_position,
            parent_index,
            open,
            level: 0,
        });
    }

    Ok(bookmarks)
}