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
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:
parent
75e5d36f70
commit
09c21ef850
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fieldlens/print",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user