1use axum::{
2 extract::{Json, Multipart},
3 http::StatusCode,
4 response::{IntoResponse, Response},
5 routing::{get, post},
6 Router,
7};
8use oxidize_pdf::{
9 operations::{merge_pdfs, MergeInput, MergeOptions},
10 parser::{PdfDocument, PdfReader},
11 Document, Font, Page,
12};
13use serde::{Deserialize, Serialize};
14use std::io::Cursor;
15use tempfile::NamedTempFile;
16use tower_http::cors::CorsLayer;
17
18#[derive(Debug, Deserialize)]
20pub struct CreatePdfRequest {
21 pub text: String,
23 pub font_size: Option<f64>,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
29pub struct ErrorResponse {
30 pub error: String,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
36pub struct ExtractTextResponse {
37 pub text: String,
39 pub pages: usize,
41}
42
43#[derive(Debug, Deserialize)]
45pub struct MergePdfRequest {
46 pub preserve_bookmarks: Option<bool>,
48 pub optimize: Option<bool>,
50}
51
52#[derive(Debug, Serialize)]
54pub struct MergePdfResponse {
55 pub message: String,
57 pub files_merged: usize,
59 pub output_size: usize,
61}
62
63#[derive(Debug)]
65pub enum AppError {
66 Pdf(oxidize_pdf::PdfError),
68 Io(std::io::Error),
70 Operation(String),
72}
73
74impl IntoResponse for AppError {
75 fn into_response(self) -> Response {
76 let error_msg = match self {
77 AppError::Pdf(e) => e.to_string(),
78 AppError::Io(e) => e.to_string(),
79 AppError::Operation(e) => e,
80 };
81
82 let error_response = ErrorResponse { error: error_msg };
83
84 (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)).into_response()
85 }
86}
87
88impl From<oxidize_pdf::PdfError> for AppError {
89 fn from(err: oxidize_pdf::PdfError) -> Self {
90 AppError::Pdf(err)
91 }
92}
93
94impl From<std::io::Error> for AppError {
95 fn from(err: std::io::Error) -> Self {
96 AppError::Io(err)
97 }
98}
99
100pub fn app() -> Router {
102 Router::new()
103 .route("/api/create", post(create_pdf))
105 .route("/api/health", get(health_check))
106 .route("/api/extract", post(extract_text))
107 .route("/api/merge", post(merge_pdfs_handler))
109 .layer(CorsLayer::permissive())
110}
111
112pub async fn create_pdf(Json(payload): Json<CreatePdfRequest>) -> Result<Response, AppError> {
114 let mut doc = Document::new();
115 let mut page = Page::a4();
116
117 let font_size = payload.font_size.unwrap_or(24.0);
118
119 page.text()
120 .set_font(Font::Helvetica, font_size)
121 .at(50.0, 750.0)
122 .write(&payload.text)?;
123
124 doc.add_page(page);
125
126 let mut pdf_bytes = Vec::new();
128 doc.write(&mut pdf_bytes)?;
129
130 Ok((
131 StatusCode::OK,
132 [
133 ("Content-Type", "application/pdf"),
134 (
135 "Content-Disposition",
136 "attachment; filename=\"generated.pdf\"",
137 ),
138 ],
139 pdf_bytes,
140 )
141 .into_response())
142}
143
144pub async fn health_check() -> impl IntoResponse {
146 Json(serde_json::json!({
147 "status": "ok",
148 "service": "oxidizePdf API",
149 "version": env!("CARGO_PKG_VERSION"),
150 }))
151}
152
153pub async fn extract_text(mut multipart: Multipart) -> Result<Response, AppError> {
155 let mut pdf_data = None;
156
157 while let Some(field) = multipart.next_field().await.map_err(|e| {
158 AppError::Io(std::io::Error::new(
159 std::io::ErrorKind::InvalidData,
160 format!("Failed to read multipart field: {e}"),
161 ))
162 })? {
163 if field.name() == Some("file") {
164 pdf_data = Some(field.bytes().await.map_err(|e| {
165 AppError::Io(std::io::Error::new(
166 std::io::ErrorKind::InvalidData,
167 format!("Failed to read file data: {e}"),
168 ))
169 })?);
170 break;
171 }
172 }
173
174 let pdf_bytes = pdf_data.ok_or_else(|| {
175 AppError::Io(std::io::Error::new(
176 std::io::ErrorKind::InvalidInput,
177 "No file provided in upload",
178 ))
179 })?;
180
181 let cursor = Cursor::new(pdf_bytes.as_ref());
183 let reader = PdfReader::new(cursor).map_err(|e| {
184 AppError::Io(std::io::Error::new(
185 std::io::ErrorKind::InvalidData,
186 format!("Failed to parse PDF: {e:?}"),
187 ))
188 })?;
189 let doc = PdfDocument::new(reader);
190
191 let extracted_texts = doc.extract_text().map_err(|e| {
192 AppError::Io(std::io::Error::new(
193 std::io::ErrorKind::InvalidData,
194 format!("Failed to extract text: {e:?}"),
195 ))
196 })?;
197
198 let text = extracted_texts
200 .into_iter()
201 .map(|et| et.text)
202 .collect::<Vec<_>>()
203 .join("\n");
204
205 let page_count = doc.page_count().unwrap_or(0) as usize;
206
207 let response = ExtractTextResponse {
208 text,
209 pages: page_count,
210 };
211
212 Ok((StatusCode::OK, Json(response)).into_response())
213}
214
215pub async fn merge_pdfs_handler(mut multipart: Multipart) -> Result<Response, AppError> {
217 let mut pdf_files = Vec::new();
218 let mut merge_options = MergeOptions::default();
219
220 while let Some(field) = multipart.next_field().await.map_err(|e| {
222 AppError::Io(std::io::Error::new(
223 std::io::ErrorKind::InvalidData,
224 format!("Failed to read multipart field: {e}"),
225 ))
226 })? {
227 let field_name = field.name().unwrap_or("").to_string();
228
229 if field_name == "files" || field_name == "files[]" {
230 let file_data = field.bytes().await.map_err(|e| {
231 AppError::Io(std::io::Error::new(
232 std::io::ErrorKind::InvalidData,
233 format!("Failed to read file data: {e}"),
234 ))
235 })?;
236 pdf_files.push(file_data);
237 } else if field_name == "options" {
238 let options_text = field.text().await.map_err(|e| {
239 AppError::Io(std::io::Error::new(
240 std::io::ErrorKind::InvalidData,
241 format!("Failed to read options: {e}"),
242 ))
243 })?;
244
245 if let Ok(request) = serde_json::from_str::<MergePdfRequest>(&options_text) {
246 if let Some(preserve_bookmarks) = request.preserve_bookmarks {
247 merge_options.preserve_bookmarks = preserve_bookmarks;
248 }
249 if let Some(optimize) = request.optimize {
250 merge_options.optimize = optimize;
251 }
252 }
253 }
254 }
255
256 if pdf_files.len() < 2 {
257 return Err(AppError::Io(std::io::Error::new(
258 std::io::ErrorKind::InvalidInput,
259 "At least 2 PDF files are required for merging",
260 )));
261 }
262
263 let mut temp_files = Vec::new();
265 let mut merge_inputs = Vec::new();
266
267 for (i, file_data) in pdf_files.iter().enumerate() {
268 let temp_file = NamedTempFile::new().map_err(|e| {
269 AppError::Io(std::io::Error::other(format!(
270 "Failed to create temp file {i}: {e}"
271 )))
272 })?;
273
274 std::fs::write(temp_file.path(), file_data).map_err(|e| {
275 AppError::Io(std::io::Error::other(format!(
276 "Failed to write temp file {i}: {e}"
277 )))
278 })?;
279
280 merge_inputs.push(MergeInput::new(temp_file.path()));
281 temp_files.push(temp_file);
282 }
283
284 let output_temp_file = NamedTempFile::new().map_err(|e| {
286 AppError::Io(std::io::Error::other(format!(
287 "Failed to create output temp file: {e}"
288 )))
289 })?;
290
291 merge_pdfs(merge_inputs, output_temp_file.path(), merge_options)
293 .map_err(|e| AppError::Operation(format!("Failed to merge PDFs: {e}")))?;
294
295 let output_data = std::fs::read(output_temp_file.path()).map_err(|e| {
297 AppError::Io(std::io::Error::other(format!(
298 "Failed to read output file: {e}"
299 )))
300 })?;
301
302 let response = MergePdfResponse {
303 message: "PDFs merged successfully".to_string(),
304 files_merged: pdf_files.len(),
305 output_size: output_data.len(),
306 };
307
308 Ok((
309 StatusCode::OK,
310 [
311 ("Content-Type", "application/pdf"),
312 ("Content-Disposition", "attachment; filename=\"merged.pdf\""),
313 ("X-Merge-Info", &serde_json::to_string(&response).unwrap()),
314 ],
315 output_data,
316 )
317 .into_response())
318}