From 881bf24f6828d40179174e3909840b12f027fafa Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sat, 13 Sep 2025 19:40:05 -0500 Subject: [PATCH 01/15] feat: add local PDF extraction for Tauri desktop and iOS apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pdf-extract crate to process PDFs locally in Rust - Create pdf_extractor module to handle PDF, TXT, and MD files - Update ChatBox to use local processing when in Tauri environment - Re-enable document upload feature for desktop/iOS users - Limit file selection to PDF, TXT, and MD formats only Benefits: - Faster document processing for desktop/iOS users - Better privacy - documents processed locally without server upload - Works offline for supported file types - No API usage limits for local processing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src-tauri/Cargo.lock | 105 ++++++++++++++++++++++++ frontend/src-tauri/Cargo.toml | 2 + frontend/src-tauri/src/lib.rs | 2 + frontend/src-tauri/src/pdf_extractor.rs | 67 +++++++++++++++ frontend/src/components/ChatBox.tsx | 93 ++++++++++++++++++++- 5 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 frontend/src-tauri/src/pdf_extractor.rs diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index d37b2760d..678a1cd72 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + [[package]] name = "aead" version = "0.5.2" @@ -1430,6 +1439,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.0" @@ -2698,6 +2716,24 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lopdf" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +dependencies = [ + "encoding_rs", + "flate2", + "indexmap 2.7.1", + "itoa 1.0.14", + "log", + "md-5", + "nom", + "rangemap", + "time", + "weezl", +] + [[package]] name = "mac" version = "0.1.1" @@ -2710,9 +2746,11 @@ version = "1.3.1" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "log", "maple-proxy", "once_cell", + "pdf-extract", "serde", "serde_json", "tauri", @@ -2787,6 +2825,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.4" @@ -3485,6 +3533,21 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pdf-extract" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" +dependencies = [ + "adobe-cmap-parser", + "encoding_rs", + "euclid", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3726,6 +3789,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "powerfmt" version = "0.2.0" @@ -4021,6 +4096,12 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rangemap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5797,6 +5878,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type1-encoding-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b" +dependencies = [ + "pom", +] + [[package]] name = "typeid" version = "1.0.2" @@ -5867,6 +5957,15 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -6236,6 +6335,12 @@ dependencies = [ "windows-core 0.60.1", ] +[[package]] +name = "weezl" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + [[package]] name = "winapi" version = "0.3.9" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index e3f77a3cc..0afe01f9c 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -36,3 +36,5 @@ maple-proxy = "0.1.2" tauri-plugin-fs = "2" anyhow = "1.0" axum = "0.8" +pdf-extract = "0.7" +base64 = "0.22" diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 6de9bde36..5aa86a4d6 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_opener; mod proxy; +mod pdf_extractor; // This handles incoming deep links fn handle_deep_link_event(url: &str, app: &tauri::AppHandle) { @@ -32,6 +33,7 @@ pub fn run() { proxy::load_proxy_config, proxy::save_proxy_settings, proxy::test_proxy_port, + pdf_extractor::extract_document_content, ]) .setup(|app| { // Initialize proxy auto-start diff --git a/frontend/src-tauri/src/pdf_extractor.rs b/frontend/src-tauri/src/pdf_extractor.rs new file mode 100644 index 000000000..557a5521d --- /dev/null +++ b/frontend/src-tauri/src/pdf_extractor.rs @@ -0,0 +1,67 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use pdf_extract::extract_text_from_mem; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct DocumentData { + pub filename: String, + pub text_content: String, + pub page_count: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DocumentResponse { + pub document: DocumentData, + pub status: String, + pub processing_time: f64, +} + +#[tauri::command] +pub async fn extract_document_content( + file_base64: String, + filename: String, + file_type: String +) -> Result { + let start_time = std::time::Instant::now(); + + // Decode base64 file data + let file_bytes = BASE64 + .decode(&file_base64) + .map_err(|e| format!("Failed to decode base64 file: {}", e))?; + + let text_content = match file_type.as_str() { + "pdf" | "application/pdf" => { + // Extract text from PDF + extract_text_from_mem(&file_bytes) + .map_err(|e| format!("Failed to extract text from PDF: {}", e))? + }, + "txt" | "text/plain" | "md" | "text/markdown" => { + // For text files, just convert bytes to string + String::from_utf8(file_bytes) + .map_err(|e| format!("Failed to decode text file: {}", e))? + }, + _ => { + return Err(format!("Unsupported file type: {}", file_type)); + } + }; + + // Count pages (for PDFs, count page breaks; for text files, estimate based on lines) + let page_count = if file_type.contains("pdf") { + text_content.matches('\x0C').count() + 1 + } else { + // Rough estimate: ~50 lines per page + (text_content.lines().count() / 50).max(1) + }; + + let processing_time = start_time.elapsed().as_secs_f64(); + + Ok(DocumentResponse { + document: DocumentData { + filename, + text_content, + page_count, + }, + status: "completed".to_string(), + processing_time, + }) +} \ No newline at end of file diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index a046d71d5..af4d06064 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -408,9 +408,71 @@ export default function Component({ let parsed: ParsedDocument; let result: DocumentResponse | undefined; - // Check if it's a text file (.txt or .md) - if (file.type === "text/plain" || file.name.endsWith(".txt") || file.name.endsWith(".md")) { - // Process text files locally + // Check if we're in Tauri environment and can process locally + const isTauriEnv = await import("@tauri-apps/api/core") + .then((m) => m.isTauri()) + .catch(() => false); + + if ( + isTauriEnv && + (file.type === "application/pdf" || + file.name.endsWith(".pdf") || + file.type === "text/plain" || + file.name.endsWith(".txt") || + file.name.endsWith(".md")) + ) { + // Process documents locally using Rust in Tauri + const { invoke } = await import("@tauri-apps/api/core"); + + // Convert file to base64 + const reader = new FileReader(); + const base64Data = await new Promise((resolve, reject) => { + reader.onload = () => { + const base64 = (reader.result as string).split(",")[1]; // Remove data:type;base64, prefix + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + // Determine file type + let fileType = file.type; + if (file.name.endsWith(".pdf")) fileType = "pdf"; + else if (file.name.endsWith(".txt")) fileType = "txt"; + else if (file.name.endsWith(".md")) fileType = "md"; + + // Call Rust function to extract content + const rustResponse = await invoke<{ + document: { filename: string; text_content: string; page_count: number }; + status: string; + processing_time: number; + }>("extract_document_content", { + fileBase64: base64Data, + filename: file.name, + fileType: fileType + }); + + // Convert Rust response to ParsedDocument format + parsed = { + document: { + filename: rustResponse.document.filename, + md_content: null, + json_content: null, + html_content: null, + text_content: rustResponse.document.text_content, + doctags_content: null + }, + status: rustResponse.status, + errors: [], + processing_time: rustResponse.processing_time, + timings: {} + }; + } else if ( + file.type === "text/plain" || + file.name.endsWith(".txt") || + file.name.endsWith(".md") + ) { + // Process text files locally in browser parsed = await processTextFileLocally(file); } else { // Upload other document types to the processing endpoint @@ -1150,7 +1212,7 @@ export default function Component({ /> )} + {/* Document upload button */} + {!uploadedDocument && ( + + )} + {/* Microphone button - show if whisper model is available */} {hasWhisperModel && ( )} - {/* Document upload button */} - {!uploadedDocument && ( + {/* Document upload button - only show in Tauri environments */} + {!uploadedDocument && isTauriEnv && (