paperless-cli 2.1.0

Rust TUI and LLM-friendly client for Paperless-ngx
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use lopdf::{Document, Object};
use serde::Serialize;
use thiserror::Error;

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct PdfMetadata {
    pub title: Option<String>,
    pub author: Option<String>,
    pub subject: Option<String>,
    pub keywords: Option<String>,
    pub creator: Option<String>,
    pub producer: Option<String>,
    pub creation_date: Option<String>,
    pub modified_date: Option<String>,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct PdfInfo {
    pub path: PathBuf,
    pub file_name: String,
    pub size_bytes: u64,
    pub page_count: usize,
    pub metadata: PdfMetadata,
}

#[derive(Debug, Error)]
pub enum PdfError {
    #[error("PDF file not found: {0}")]
    MissingFile(PathBuf),
    #[error("Failed to load PDF {path}: {source}")]
    Load {
        path: PathBuf,
        #[source]
        source: lopdf::Error,
    },
    #[error("Failed to extract text from PDF {path}: {source}")]
    ExtractText {
        path: PathBuf,
        #[source]
        source: lopdf::Error,
    },
    #[error("Failed to inspect PDF metadata for {path}: {reason}")]
    Metadata { path: PathBuf, reason: String },
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

pub fn read_local_pdf_text(path: impl AsRef<Path>) -> Result<String, PdfError> {
    let path = path.as_ref();
    ensure_file_exists(path)?;

    let document = load_pdf(path)?;
    let pages = document.get_pages();
    let page_numbers = pages.keys().copied().collect::<Vec<_>>();
    if page_numbers.is_empty() {
        return Ok(String::new());
    }

    let text = document
        .extract_text(&page_numbers)
        .map_err(|source| PdfError::ExtractText {
            path: path.to_path_buf(),
            source,
        })?;

    Ok(normalize_pdf_text(&text))
}

pub fn read_local_pdf_info(path: impl AsRef<Path>) -> Result<PdfInfo, PdfError> {
    let path = path.as_ref();
    ensure_file_exists(path)?;

    let document = load_pdf(path)?;
    let page_count = document.get_pages().len();
    let size_bytes = fs::metadata(path)?.len();
    let metadata = extract_metadata(&document, path)?;

    Ok(PdfInfo {
        path: path.to_path_buf(),
        file_name: path
            .file_name()
            .and_then(|part| part.to_str())
            .unwrap_or("document.pdf")
            .to_string(),
        size_bytes,
        page_count,
        metadata,
    })
}

fn ensure_file_exists(path: &Path) -> Result<(), PdfError> {
    if !path.is_file() {
        return Err(PdfError::MissingFile(path.to_path_buf()));
    }
    Ok(())
}

fn load_pdf(path: &Path) -> Result<Document, PdfError> {
    Document::load(path).map_err(|source| PdfError::Load {
        path: path.to_path_buf(),
        source,
    })
}

fn normalize_pdf_text(text: &str) -> String {
    text.replace("\r\n", "\n").trim().to_string()
}

fn extract_metadata(document: &Document, path: &Path) -> Result<PdfMetadata, PdfError> {
    let info = match document.trailer.get(b"Info") {
        Ok(Object::Reference(reference)) => document
            .get_object(*reference)
            .map_err(|reason| PdfError::Metadata {
                path: path.to_path_buf(),
                reason: reason.to_string(),
            })?
            .as_dict()
            .map_err(|reason| PdfError::Metadata {
                path: path.to_path_buf(),
                reason: reason.to_string(),
            })?,
        Ok(Object::Dictionary(dictionary)) => dictionary,
        Ok(other) => {
            return Err(PdfError::Metadata {
                path: path.to_path_buf(),
                reason: format!("unsupported Info object: {other:?}"),
            });
        }
        Err(_) => {
            return Ok(PdfMetadata::default());
        }
    };

    Ok(PdfMetadata {
        title: read_info_string(info.get(b"Title").ok()),
        author: read_info_string(info.get(b"Author").ok()),
        subject: read_info_string(info.get(b"Subject").ok()),
        keywords: read_info_string(info.get(b"Keywords").ok()),
        creator: read_info_string(info.get(b"Creator").ok()),
        producer: read_info_string(info.get(b"Producer").ok()),
        creation_date: read_info_string(info.get(b"CreationDate").ok()),
        modified_date: read_info_string(info.get(b"ModDate").ok()),
    })
}

fn read_info_string(value: Option<&Object>) -> Option<String> {
    let value = value?;
    match value {
        Object::String(bytes, _) => decode_pdf_string(bytes),
        Object::Name(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
        Object::Integer(integer) => Some(integer.to_string()),
        Object::Real(real) => Some(real.to_string()),
        Object::Boolean(boolean) => Some(boolean.to_string()),
        _ => Some(format!("{value:?}")),
    }
}

fn decode_pdf_string(bytes: &[u8]) -> Option<String> {
    if bytes.is_empty() {
        return Some(String::new());
    }

    if bytes.starts_with(&[0xFE, 0xFF]) {
        let mut units = Vec::with_capacity((bytes.len().saturating_sub(2)) / 2);
        for chunk in bytes[2..].chunks_exact(2) {
            units.push(u16::from_be_bytes([chunk[0], chunk[1]]));
        }
        return String::from_utf16(&units).ok();
    }

    if bytes.starts_with(&[0xFF, 0xFE]) {
        let mut units = Vec::with_capacity((bytes.len().saturating_sub(2)) / 2);
        for chunk in bytes[2..].chunks_exact(2) {
            units.push(u16::from_le_bytes([chunk[0], chunk[1]]));
        }
        return String::from_utf16(&units).ok();
    }

    Some(String::from_utf8_lossy(bytes).into_owned())
}