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",
|
"name": "@fieldlens/print",
|
||||||
"version": "0.1.13",
|
"version": "0.1.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "fieldlens-print"
|
name = "fieldlens-print"
|
||||||
version = "0.1.13"
|
version = "0.1.14"
|
||||||
description = "FieldLens print-stream barcode capture companion"
|
description = "FieldLens print-stream barcode capture companion"
|
||||||
authors = ["Arrowstack"]
|
authors = ["Arrowstack"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@ -354,6 +354,24 @@ fn spool_folder_loop(
|
|||||||
.unwrap_or("spool-job")
|
.unwrap_or("spool-job")
|
||||||
.to_string();
|
.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) {
|
match fs::read(&path) {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let is_test_capture = already_forwarded && label.starts_with("test-");
|
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 {
|
fn is_supported_drop(path: &std::path::Path) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
path.extension()
|
path.extension()
|
||||||
|
|||||||
@ -65,6 +65,9 @@ impl SharedState {
|
|||||||
pub fn push_job(&self, app: &AppHandle, job: JobSummary) {
|
pub fn push_job(&self, app: &AppHandle, job: JobSummary) {
|
||||||
{
|
{
|
||||||
let mut jobs = self.jobs.lock();
|
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());
|
jobs.push_front(job.clone());
|
||||||
while jobs.len() > MAX_JOBS {
|
while jobs.len() > MAX_JOBS {
|
||||||
jobs.pop_back();
|
jobs.pop_back();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "FieldLens Print",
|
"productName": "FieldLens Print",
|
||||||
"version": "0.1.13",
|
"version": "0.1.14",
|
||||||
"identifier": "dev.arrowstack.fieldlens.print",
|
"identifier": "dev.arrowstack.fieldlens.print",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@ -39,7 +39,9 @@
|
|||||||
"deb": {
|
"deb": {
|
||||||
"depends": [
|
"depends": [
|
||||||
"cups-client",
|
"cups-client",
|
||||||
"python3"
|
"python3",
|
||||||
|
"poppler-utils",
|
||||||
|
"zbar-tools"
|
||||||
],
|
],
|
||||||
"recommends": [
|
"recommends": [
|
||||||
"policykit-1"
|
"policykit-1"
|
||||||
|
|||||||
@ -85,7 +85,7 @@ let config: AppConfig
|
|||||||
let jobs: Job[] = []
|
let jobs: Job[] = []
|
||||||
let status: Status
|
let status: Status
|
||||||
let loginTimer: number | undefined
|
let loginTimer: number | undefined
|
||||||
let currentVersion = '0.1.13'
|
let currentVersion = '0.1.14'
|
||||||
let bundleType: BundleType | null = null
|
let bundleType: BundleType | null = null
|
||||||
let availableUpdate: Update | null = null
|
let availableUpdate: Update | null = null
|
||||||
let availableManualUpdate: ManualUpdate | null = null
|
let availableManualUpdate: ManualUpdate | null = null
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user