feat(linux): decode captured PDFs one page at a time
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled

This commit is contained in:
Admin 2026-09-24 09:27:22 +00:00
parent 75e5d36f70
commit 09c21ef850
6 changed files with 276 additions and 5 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@fieldlens/print",
"version": "0.1.13",
"version": "0.1.14",
"private": true,
"type": "module",
"scripts": {

View File

@ -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"

View File

@ -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<AtomicBool>,
) {
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<u32, String> {
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::<u32>().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<Vec<crate::model::DecodedBarcode>, 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()

View File

@ -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();

View File

@ -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"

View File

@ -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