printwell-cli 0.1.11

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

use anyhow::{Context, Result};

use crate::cli::args::AnnotateArgs;
use crate::cli::utils::{parse_colon_spec, parse_color_hex, parse_rect_from_parts};

pub fn annotate(args: &AnnotateArgs) -> Result<()> {
    use printwell::annotations::{AnnotationType, Color, add_annotations};

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

    // List annotations
    if args.list {
        return list_annotations_cmd(&pdf_data, args);
    }

    // Remove annotations
    if args.remove {
        return remove_annotations_cmd(&pdf_data, args);
    }

    // Add annotations
    let mut annotations_to_add = Vec::new();
    let default_color = Color::from_hex(&args.color).unwrap_or(Color::yellow());

    // Parse highlight annotations
    for spec in &args.highlight {
        let annot = parse_annotation_spec(spec, AnnotationType::Highlight, default_color)
            .with_context(|| format!("Invalid highlight spec: {spec}"))?;
        annotations_to_add.push(annot);
    }

    // Parse underline annotations
    for spec in &args.underline {
        let annot = parse_annotation_spec(spec, AnnotationType::Underline, default_color)
            .with_context(|| format!("Invalid underline spec: {spec}"))?;
        annotations_to_add.push(annot);
    }

    // Parse strikeout annotations
    for spec in &args.strikeout {
        let annot = parse_annotation_spec(spec, AnnotationType::Strikeout, default_color)
            .with_context(|| format!("Invalid strikeout spec: {spec}"))?;
        annotations_to_add.push(annot);
    }

    // Parse square annotations
    for spec in &args.square {
        let annot = parse_annotation_spec(spec, AnnotationType::Square, default_color)
            .with_context(|| format!("Invalid square spec: {spec}"))?;
        annotations_to_add.push(annot);
    }

    // Parse text note annotations
    for spec in &args.note {
        let annot = parse_note_spec(spec, default_color)
            .with_context(|| format!("Invalid note spec: {spec}"))?;
        annotations_to_add.push(annot);
    }

    if annotations_to_add.is_empty() {
        anyhow::bail!(
            "No annotations specified. Use --highlight, --note, --underline, --strikeout, or --square"
        );
    }

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

    let result = add_annotations(&pdf_data, &annotations_to_add)
        .context("Failed to add annotations to PDF")?;

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

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

    Ok(())
}

fn list_annotations_cmd(pdf_data: &[u8], args: &AnnotateArgs) -> Result<()> {
    use printwell::annotations::list_annotations;

    let annotations = list_annotations(pdf_data).context("Failed to list annotations")?;

    match args.format.as_str() {
        "json" => {
            let json = serde_json::to_string_pretty(
                &annotations
                    .iter()
                    .map(|a| {
                        serde_json::json!({
                            "type": format!("{:?}", a.annotation_type),
                            "page": a.page,
                            "rect": {
                                "x": a.rect.x,
                                "y": a.rect.y,
                                "width": a.rect.width,
                                "height": a.rect.height,
                            },
                            "contents": a.contents,
                            "author": a.author,
                        })
                    })
                    .collect::<Vec<_>>(),
            )
            .context("Failed to serialize annotations")?;
            println!("{json}");
        }
        _ => {
            if annotations.is_empty() {
                println!("No annotations found in document.");
            } else {
                println!("Found {} annotation(s):\n", annotations.len());
                for (i, a) in annotations.iter().enumerate() {
                    println!("#{}: {:?} on page {}", i + 1, a.annotation_type, a.page);
                    println!(
                        "    Rect: ({:.1}, {:.1}) {}x{}",
                        a.rect.x, a.rect.y, a.rect.width, a.rect.height
                    );
                    if let Some(ref contents) = a.contents {
                        println!("    Contents: {contents}");
                    }
                    if let Some(ref author) = a.author {
                        println!("    Author: {author}");
                    }
                    println!();
                }
            }
        }
    }

    Ok(())
}

fn remove_annotations_cmd(pdf_data: &[u8], args: &AnnotateArgs) -> Result<()> {
    use printwell::annotations::remove_annotations;

    let output_path = args
        .output
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("--output is required when removing annotations"))?;

    let result = remove_annotations(pdf_data, args.page, None)
        .context("Failed to remove annotations from PDF")?;

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

    eprintln!("Removed annotations, written to: {output_path}");

    Ok(())
}

fn parse_annotation_spec(
    spec: &str,
    annotation_type: printwell::annotations::AnnotationType,
    default_color: printwell::annotations::Color,
) -> Result<printwell::annotations::Annotation> {
    use printwell::annotations::{Annotation, Color, Rect};

    let parts = parse_colon_spec(spec, 5, "page:x:y:width:height[:color]")?;

    let page: u32 = parts[0]
        .parse()
        .with_context(|| format!("Invalid page number: {}", parts[0]))?;
    let (pos_x, pos_y, width, height) = parse_rect_from_parts(&parts, 1)?;

    let color = if parts.len() > 5 {
        let (red, green, blue) = parse_color_hex(parts[5]).unwrap_or((
            default_color.r,
            default_color.g,
            default_color.b,
        ));
        Color::new(red, green, blue, 255)
    } else {
        default_color
    };

    Ok(Annotation::builder()
        .annotation_type(annotation_type)
        .page(page)
        .rect(Rect::new(pos_x, pos_y, width, height))
        .color(color)
        .build())
}

fn parse_note_spec(
    spec: &str,
    default_color: printwell::annotations::Color,
) -> Result<printwell::annotations::Annotation> {
    use printwell::annotations::{Annotation, AnnotationType, Rect};

    let parts = parse_colon_spec(spec, 4, "page:x:y:contents")?;

    let page: u32 = parts[0]
        .parse()
        .with_context(|| format!("Invalid page number: {}", parts[0]))?;
    let pos_x: f32 = parts[1]
        .parse()
        .with_context(|| format!("Invalid x coordinate: {}", parts[1]))?;
    let pos_y: f32 = parts[2]
        .parse()
        .with_context(|| format!("Invalid y coordinate: {}", parts[2]))?;
    let contents = parts[3..].join(":");

    Ok(Annotation::builder()
        .annotation_type(AnnotationType::Text)
        .page(page)
        .rect(Rect::new(pos_x, pos_y, 24.0, 24.0))
        .color(default_color)
        .contents(contents)
        .build())
}