From 09c21ef8502607cc9dbb34ba6b00958f59513fe1 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 24 Sep 2026 09:27:22 +0000 Subject: [PATCH] feat(linux): decode captured PDFs one page at a time --- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/capture.rs | 266 ++++++++++++++++++++++++++++++++++++++ src-tauri/src/state.rs | 3 + src-tauri/tauri.conf.json | 6 +- src/main.ts | 2 +- 6 files changed, 276 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 064dfc3..e4f3fbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fieldlens/print", - "version": "0.1.13", + "version": "0.1.14", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 24e339c..9356530 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fieldlens-print" -version = "0.1.13" +version = "0.1.14" description = "FieldLens print-stream barcode capture companion" authors = ["Arrowstack"] edition = "2021" diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index a52e228..a1a0208 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -354,6 +354,24 @@ fn spool_folder_loop( .unwrap_or("spool-job") .to_string(); + let is_pdf = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.eq_ignore_ascii_case("pdf")) + .unwrap_or(false); + + if already_forwarded && is_pdf { + process_captured_pdf( + &path, + &processed_dir, + &label, + &state, + &app, + &stop, + ); + continue; + } + match fs::read(&path) { Ok(bytes) => { let is_test_capture = already_forwarded && label.starts_with("test-"); @@ -391,6 +409,254 @@ fn spool_folder_loop( } } + +#[cfg(target_os = "linux")] +fn process_captured_pdf( + path: &std::path::Path, + processed_dir: &std::path::Path, + label: &str, + state: &SharedState, + app: &AppHandle, + stop: &Arc, +) { + let id = Uuid::new_v4().to_string(); + let is_test_capture = label.starts_with("test-"); + let source = if is_test_capture { "cups-test" } else { "cups-original" }; + let forwarded = !is_test_capture; + let bytes = fs::metadata(path) + .map(|metadata| metadata.len().min(usize::MAX as u64) as usize) + .unwrap_or(0); + + let target = unique_processed_path(processed_dir, label); + if let Err(error) = fs::rename(path, &target) { + state.push_job( + app, + JobSummary { + id, + source: source.into(), + label: label.to_string(), + bytes, + barcodes: vec![], + captured_at: chrono::Utc::now().to_rfc3339(), + forwarded, + error: Some(format!("archive captured PDF failed: {error}")), + }, + ); + return; + } + + let mut job = JobSummary { + id: id.clone(), + source: source.into(), + label: label.to_string(), + bytes, + barcodes: vec![], + captured_at: chrono::Utc::now().to_rfc3339(), + forwarded, + error: None, + }; + + // Publish the job before any PDF rendering/decoding. A large document can + // take time to inspect, but capture/print success should be visible + // immediately and the UI must never wait for the whole document. + state.push_job(app, job.clone()); + + let page_count = match pdf_page_count(&target) { + Ok(count) => count, + Err(error) => { + job.error = Some(error); + state.push_job(app, job); + return; + } + }; + + for page in 1..=page_count { + if stop.load(Ordering::SeqCst) { + job.error = Some(format!( + "PDF barcode decoding stopped after page {} of {}", + page.saturating_sub(1), + page_count + )); + state.push_job(app, job); + return; + } + + match decode_pdf_page(&target, page, &id) { + Ok(found) => { + for barcode in found { + if job.barcodes.iter().any(|existing| + existing.format == barcode.format && existing.value == barcode.value + ) { + continue; + } + + state.queue_scan( + barcode.value.clone(), + barcode.format.clone(), + "print".into(), + &id, + label, + ); + job.barcodes.push(barcode); + } + // Upsert the same job after each page so barcode results can + // appear incrementally without creating duplicate jobs. + state.push_job(app, job.clone()); + } + Err(error) => { + job.error = Some(format!( + "PDF page {page}/{page_count} decode failed: {error}" + )); + state.push_job(app, job.clone()); + // Page failures are non-fatal; keep processing subsequent pages. + } + } + } +} + +#[cfg(target_os = "linux")] +fn pdf_page_count(path: &std::path::Path) -> Result { + let output = Command::new("pdfinfo") + .arg(path) + .output() + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "PDF page decoding requires poppler-utils (pdfinfo/pdftoppm)".to_string() + } else { + format!("run pdfinfo: {error}") + } + })?; + + if !output.status.success() { + return Err(format!( + "pdfinfo failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let pages = stdout + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + if !key.trim().eq_ignore_ascii_case("pages") { + return None; + } + value.trim().parse::().ok() + }) + .ok_or_else(|| "pdfinfo did not report a page count".to_string())?; + + if pages == 0 { + return Err("captured PDF contains no pages".into()); + } + + Ok(pages) +} + +#[cfg(target_os = "linux")] +fn decode_pdf_page( + path: &std::path::Path, + page: u32, + job_id: &str, +) -> Result, String> { + let prefix = std::env::temp_dir().join(format!( + "fieldlens-{}-page-{}", + job_id, + page + )); + let image_path = prefix.with_extension("png"); + + let render = Command::new("pdftoppm") + .args([ + "-f", + &page.to_string(), + "-l", + &page.to_string(), + "-singlefile", + "-png", + "-r", + "200", + ]) + .arg(path) + .arg(&prefix) + .output() + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "PDF page decoding requires poppler-utils (pdftoppm)".to_string() + } else { + format!("run pdftoppm: {error}") + } + })?; + + if !render.status.success() { + let _ = fs::remove_file(&image_path); + return Err(format!( + "pdftoppm failed: {}", + String::from_utf8_lossy(&render.stderr).trim() + )); + } + + let decoded = Command::new("zbarimg") + .args(["--quiet", "--nodbus"]) + .arg(&image_path) + .output(); + + let _ = fs::remove_file(&image_path); + + let output = match decoded { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err("PDF barcode decoding requires zbar-tools (zbarimg)".into()) + } + Err(error) => return Err(format!("run zbarimg: {error}")), + }; + + // zbarimg returns a non-zero status when no symbols are found. That is a + // normal page result, not a print/capture failure. + if output.stdout.is_empty() { + return Ok(Vec::new()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut found = Vec::new(); + + for line in stdout.lines() { + let Some((format, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + if value.is_empty() { + continue; + } + + let format = match format.trim().to_ascii_uppercase().as_str() { + "CODE-128" => "CODE_128", + "CODE-39" => "CODE_39", + "EAN-13" => "EAN_13", + "EAN-8" => "EAN_8", + "UPC-A" => "UPC_A", + "UPC-E" => "UPC_E", + "I2/5" => "ITF", + "QR-CODE" => "QR_CODE", + other => other, + } + .to_string(); + + let barcode = crate::model::DecodedBarcode { + format, + value: value.to_string(), + }; + + if !found.iter().any(|existing: &crate::model::DecodedBarcode| + existing.format == barcode.format && existing.value == barcode.value + ) { + found.push(barcode); + } + } + + Ok(found) +} + fn is_supported_drop(path: &std::path::Path) -> bool { matches!( path.extension() diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index d534216..02cef22 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -65,6 +65,9 @@ impl SharedState { pub fn push_job(&self, app: &AppHandle, job: JobSummary) { { let mut jobs = self.jobs.lock(); + if let Some(index) = jobs.iter().position(|existing| existing.id == job.id) { + jobs.remove(index); + } jobs.push_front(job.clone()); while jobs.len() > MAX_JOBS { jobs.pop_back(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d430785..6f6b09c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "FieldLens Print", - "version": "0.1.13", + "version": "0.1.14", "identifier": "dev.arrowstack.fieldlens.print", "build": { "beforeDevCommand": "npm run dev", @@ -39,7 +39,9 @@ "deb": { "depends": [ "cups-client", - "python3" + "python3", + "poppler-utils", + "zbar-tools" ], "recommends": [ "policykit-1" diff --git a/src/main.ts b/src/main.ts index 42daf1a..140e168 100644 --- a/src/main.ts +++ b/src/main.ts @@ -85,7 +85,7 @@ let config: AppConfig let jobs: Job[] = [] let status: Status let loginTimer: number | undefined -let currentVersion = '0.1.13' +let currentVersion = '0.1.14' let bundleType: BundleType | null = null let availableUpdate: Update | null = null let availableManualUpdate: ManualUpdate | null = null