Skip to main content

paperless_cli/
pdf.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use lopdf::{Document, Object};
5use serde::Serialize;
6use thiserror::Error;
7
8#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
9pub struct PdfMetadata {
10    pub title: Option<String>,
11    pub author: Option<String>,
12    pub subject: Option<String>,
13    pub keywords: Option<String>,
14    pub creator: Option<String>,
15    pub producer: Option<String>,
16    pub creation_date: Option<String>,
17    pub modified_date: Option<String>,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
21pub struct PdfInfo {
22    pub path: PathBuf,
23    pub file_name: String,
24    pub size_bytes: u64,
25    pub page_count: usize,
26    pub metadata: PdfMetadata,
27}
28
29#[derive(Debug, Error)]
30pub enum PdfError {
31    #[error("PDF file not found: {0}")]
32    MissingFile(PathBuf),
33    #[error("Failed to load PDF {path}: {source}")]
34    Load {
35        path: PathBuf,
36        #[source]
37        source: lopdf::Error,
38    },
39    #[error("Failed to extract text from PDF {path}: {source}")]
40    ExtractText {
41        path: PathBuf,
42        #[source]
43        source: lopdf::Error,
44    },
45    #[error("Failed to inspect PDF metadata for {path}: {reason}")]
46    Metadata { path: PathBuf, reason: String },
47    #[error(transparent)]
48    Io(#[from] std::io::Error),
49}
50
51pub fn read_local_pdf_text(path: impl AsRef<Path>) -> Result<String, PdfError> {
52    let path = path.as_ref();
53    ensure_file_exists(path)?;
54
55    let document = load_pdf(path)?;
56    let pages = document.get_pages();
57    let page_numbers = pages.keys().copied().collect::<Vec<_>>();
58    if page_numbers.is_empty() {
59        return Ok(String::new());
60    }
61
62    let text = document
63        .extract_text(&page_numbers)
64        .map_err(|source| PdfError::ExtractText {
65            path: path.to_path_buf(),
66            source,
67        })?;
68
69    Ok(normalize_pdf_text(&text))
70}
71
72pub fn read_local_pdf_info(path: impl AsRef<Path>) -> Result<PdfInfo, PdfError> {
73    let path = path.as_ref();
74    ensure_file_exists(path)?;
75
76    let document = load_pdf(path)?;
77    let page_count = document.get_pages().len();
78    let size_bytes = fs::metadata(path)?.len();
79    let metadata = extract_metadata(&document, path)?;
80
81    Ok(PdfInfo {
82        path: path.to_path_buf(),
83        file_name: path
84            .file_name()
85            .and_then(|part| part.to_str())
86            .unwrap_or("document.pdf")
87            .to_string(),
88        size_bytes,
89        page_count,
90        metadata,
91    })
92}
93
94fn ensure_file_exists(path: &Path) -> Result<(), PdfError> {
95    if !path.is_file() {
96        return Err(PdfError::MissingFile(path.to_path_buf()));
97    }
98    Ok(())
99}
100
101fn load_pdf(path: &Path) -> Result<Document, PdfError> {
102    Document::load(path).map_err(|source| PdfError::Load {
103        path: path.to_path_buf(),
104        source,
105    })
106}
107
108fn normalize_pdf_text(text: &str) -> String {
109    text.replace("\r\n", "\n").trim().to_string()
110}
111
112fn extract_metadata(document: &Document, path: &Path) -> Result<PdfMetadata, PdfError> {
113    let info = match document.trailer.get(b"Info") {
114        Ok(Object::Reference(reference)) => document
115            .get_object(*reference)
116            .map_err(|reason| PdfError::Metadata {
117                path: path.to_path_buf(),
118                reason: reason.to_string(),
119            })?
120            .as_dict()
121            .map_err(|reason| PdfError::Metadata {
122                path: path.to_path_buf(),
123                reason: reason.to_string(),
124            })?,
125        Ok(Object::Dictionary(dictionary)) => dictionary,
126        Ok(other) => {
127            return Err(PdfError::Metadata {
128                path: path.to_path_buf(),
129                reason: format!("unsupported Info object: {other:?}"),
130            });
131        }
132        Err(_) => {
133            return Ok(PdfMetadata::default());
134        }
135    };
136
137    Ok(PdfMetadata {
138        title: read_info_string(info.get(b"Title").ok()),
139        author: read_info_string(info.get(b"Author").ok()),
140        subject: read_info_string(info.get(b"Subject").ok()),
141        keywords: read_info_string(info.get(b"Keywords").ok()),
142        creator: read_info_string(info.get(b"Creator").ok()),
143        producer: read_info_string(info.get(b"Producer").ok()),
144        creation_date: read_info_string(info.get(b"CreationDate").ok()),
145        modified_date: read_info_string(info.get(b"ModDate").ok()),
146    })
147}
148
149fn read_info_string(value: Option<&Object>) -> Option<String> {
150    let value = value?;
151    match value {
152        Object::String(bytes, _) => decode_pdf_string(bytes),
153        Object::Name(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
154        Object::Integer(integer) => Some(integer.to_string()),
155        Object::Real(real) => Some(real.to_string()),
156        Object::Boolean(boolean) => Some(boolean.to_string()),
157        _ => Some(format!("{value:?}")),
158    }
159}
160
161fn decode_pdf_string(bytes: &[u8]) -> Option<String> {
162    if bytes.is_empty() {
163        return Some(String::new());
164    }
165
166    if bytes.starts_with(&[0xFE, 0xFF]) {
167        let mut units = Vec::with_capacity((bytes.len().saturating_sub(2)) / 2);
168        for chunk in bytes[2..].chunks_exact(2) {
169            units.push(u16::from_be_bytes([chunk[0], chunk[1]]));
170        }
171        return String::from_utf16(&units).ok();
172    }
173
174    if bytes.starts_with(&[0xFF, 0xFE]) {
175        let mut units = Vec::with_capacity((bytes.len().saturating_sub(2)) / 2);
176        for chunk in bytes[2..].chunks_exact(2) {
177            units.push(u16::from_le_bytes([chunk[0], chunk[1]]));
178        }
179        return String::from_utf16(&units).ok();
180    }
181
182    Some(String::from_utf8_lossy(bytes).into_owned())
183}