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))?;
if args.extract.is_some() || (args.add.is_empty() && args.from_json.is_none()) {
return extract_bookmarks_cmd(&pdf_data, args);
}
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();
for spec in &args.add {
let bm =
parse_bookmark_spec(spec).with_context(|| format!("Invalid bookmark spec: {spec}"))?;
bookmarks_to_add.push(bm);
}
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 {
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 {
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)
}