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}