From d71f024c58d3e6ff3578a8e32b3d6564094c3f0b Mon Sep 17 00:00:00 2001 From: TraceLumen Date: Thu, 17 Sep 2026 18:59:00 +0300 Subject: [PATCH] Dualside printing test realisation --- src/main.rs | 190 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 124 insertions(+), 66 deletions(-) diff --git a/src/main.rs b/src/main.rs index 507551c..9056599 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,6 +51,7 @@ struct PrintSettings { enum FileStatus { Queued, Printing, + AwaitingFlip, Awaiting, Done, Error, @@ -62,6 +63,7 @@ enum FileStatus { enum JobPhase { WaitingStart, Printing, + AwaitingFlip, AwaitingPickup, Finished, Cancelled, @@ -73,6 +75,9 @@ struct JobFileView { status: FileStatus, #[serde(skip_serializing_if = "Option::is_none")] error: Option, + /// true → нечётное кол-во страниц, нужно убрать верхний лист перед 2-м проходом + #[serde(default)] + remove_top_sheet: bool, } #[derive(Clone, Debug, Serialize)] @@ -103,17 +108,29 @@ impl Job { JobView { job_id: self.id.clone(), phase: self.phase.clone(), - files: self - .files - .iter() - .map(|f| JobFileView { + files: self.files.iter().map(|f| { + JobFileView { name: f.name.clone(), status: f.status.clone(), error: f.error.clone(), - }) - .collect(), + remove_top_sheet: Self::should_remove_top_sheet(&f.settings), + } + }).collect(), } } + + /// Нечетных страниц больше, чем чётных → лишний лист сверху + fn should_remove_top_sheet(settings: &Option) -> bool { + let Some(s) = settings else { return false }; + if s.sides.as_deref() != Some("Двусторонняя") { return false; } + if s.pages == "all" { return false; } // для офисных не знаем кол-во + let nums: Vec = s.pages.split(',') + .filter_map(|p| p.trim().parse().ok()) + .collect(); + let odd = nums.iter().filter(|n| *n % 2 == 1).count(); + let even = nums.iter().filter(|n| *n % 2 == 0).count(); + odd > even + } } #[derive(Clone)] @@ -122,6 +139,7 @@ struct SpawnInfo { idx: usize, path: PathBuf, settings: Option, + is_second_pass: bool, } type JobStore = Arc>>; @@ -279,30 +297,48 @@ async fn job_advance( ) -> Result, (StatusCode, String)> { let (view, spawn_info) = { let mut m = store.lock().unwrap(); - let job = m - .get_mut(&job_id) + let job = m.get_mut(&job_id) .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?; + let si = match job.phase { JobPhase::WaitingStart => { - info!(job_id = %job_id, "▶️ Клиент дал старт: печать первого файла"); + info!(job_id = %job_id, "▶️ Старт первого файла"); Some(start_printing_locked(job, 0)) } + + JobPhase::AwaitingFlip => { + if let Some(c) = job.current { + info!(job_id = %job_id, file = %job.files[c].name, + "🔄 Запуск второго прохода (чётные страницы)"); + job.files[c].status = FileStatus::Printing; + job.phase = JobPhase::Printing; + job.cups_id = None; + Some(SpawnInfo { + job_id: job.id.clone(), + idx: c, + path: job.files[c].path.to_path_buf(), + settings: job.files[c].settings.clone(), + is_second_pass: true, + }) + } else { + None + } + } + JobPhase::AwaitingPickup => match job.current.take() { Some(c) => { job.files[c].status = FileStatus::Done; - info!(job_id = %job_id, file = %job.files[c].name, "✔️ Пользователь забрал документ"); + info!(job_id = %job_id, file = %job.files[c].name, "✔️ Документ забран"); begin_next_locked(job, c) } - None => { - job.phase = JobPhase::Finished; - None - } + None => { job.phase = JobPhase::Finished; None } }, - // printing / finished / cancelled — идемпотентно игнорируем + _ => None, }; (job.view(), si) }; + if let Some(si) = spawn_info { spawn_print_task(store.clone(), si); } @@ -362,6 +398,7 @@ fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo { idx, path: job.files[idx].path.to_path_buf(), settings: job.files[idx].settings.clone(), + is_second_pass: false, } } @@ -380,57 +417,74 @@ fn begin_next_locked(job: &mut Job, from: usize) -> Option { fn spawn_print_task(store: JobStore, info: SpawnInfo) { tokio::spawn(async move { - let path = info.path.clone(); + let path = info.path.clone(); let settings = info.settings.clone(); + let is_duplex = settings.as_ref() + .map(|s| s.sides.as_deref() == Some("Двусторонняя")) + .unwrap_or(false); + + let duplex_pass: Option<&str> = if is_duplex { + if info.is_second_pass { Some("even") } else { Some("odd") } + } else { + None + }; + // 1) Спулим в CUPS - let spool = - tokio::task::spawn_blocking(move || run_lp(&path, settings.as_ref())).await; + let p = path.clone(); + let s = settings.clone(); + let dp = duplex_pass.map(|d| d.to_string()); + let spool = tokio::task::spawn_blocking(move || { + run_lp(&p, s.as_ref(), dp.as_deref()) + }).await; + let cups_id = match spool { Ok(Ok(id)) => id, Ok(Err(e)) => return mark_error_and_continue(store, info, e).await, - Err(e) => { - return mark_error_and_continue(store, info, format!("spawn: {}", e)).await - } + Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await, }; - // 2) Ждём физического завершения печати + // 2) Ждём физического завершения if !cups_id.is_empty() { { let mut m = store.lock().unwrap(); - match m.get_mut(&info.job_id) { - Some(job) if job.phase != JobPhase::Cancelled => { - job.cups_id = Some(cups_id.clone()) - } - _ => return, - } + if let Some(job) = m.get_mut(&info.job_id) { + if job.phase != JobPhase::Cancelled { + job.cups_id = Some(cups_id.clone()); + } else { return; } + } else { return; } } let cid = cups_id.clone(); let wait = tokio::task::spawn_blocking(move || wait_for_cups(&cid)).await; match wait { Ok(Ok(())) => {} Ok(Err(e)) => return mark_error_and_continue(store, info, e).await, - Err(e) => { - return mark_error_and_continue(store, info, format!("spawn: {}", e)).await - } + Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await, } } - // 3) Допечатано → ждём подтверждения пользователя + // 3) Обновляем статус let mut m = store.lock().unwrap(); - let job = match m.get_mut(&info.job_id) { - Some(j) => j, - None => return, - }; - if job.phase == JobPhase::Cancelled - || job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) - { - return; + let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return }; + + if job.phase == JobPhase::Cancelled { return; } + if job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) { return; } + + if is_duplex && !info.is_second_pass { + // Первый проход завершён → ждём, пока пользователь переложит бумагу + job.files[info.idx].status = FileStatus::AwaitingFlip; + job.phase = JobPhase::AwaitingFlip; + job.cups_id = None; + info!(job_id = %info.job_id, file = %job.files[info.idx].name, + "🔄 Первый проход завершён, ждём переворота бумаги"); + } else { + // Обычная печать или второй проход дуплекса → ждём, пока заберут + job.files[info.idx].status = FileStatus::Awaiting; + job.phase = JobPhase::AwaitingPickup; + job.cups_id = None; + info!(job_id = %info.job_id, file = %job.files[info.idx].name, + "✅ Печать завершена, ждём подтверждения"); } - job.files[info.idx].status = FileStatus::Awaiting; - job.phase = JobPhase::AwaitingPickup; - job.cups_id = None; - info!(job_id = %info.job_id, file = %job.files[info.idx].name, "✅ Файл допечатан, ждём подтверждения клиента"); }); } @@ -487,11 +541,13 @@ fn normalize_format(format: &str) -> String { } } -fn run_lp(path: &Path, settings: Option<&PrintSettings>) -> Result { +fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&str>) -> Result { let lp = cups_bin("lp"); let mut cmd = Command::new(&lp); + if let Some(s) = settings { cmd.arg("-n").arg(s.copies.to_string()); + if s.pages != "all" && !s.pages.is_empty() { cmd.arg("-P").arg(&s.pages); } @@ -502,40 +558,42 @@ fn run_lp(path: &Path, settings: Option<&PrintSettings>) -> Result { + + if let Some(d) = s.dpi.as_deref() { + if !d.is_empty() && d != "Авто" { cmd.arg("-o").arg(format!("printer-resolution={}dpi", d)); } - _ => {} } } + + // Ручной дуплекс: разбивка на чёт/нечёт + match duplex_pass { + Some("odd") => { + cmd.arg("-o").arg("page-set=odd"); + } + Some("even") => { + cmd.arg("-o").arg("page-set=even"); + cmd.arg("-o").arg("outputorder=reverse"); + } + _ => {} + } + cmd.arg(path); - let args: Vec = cmd - .get_args() - .map(|a| a.to_string_lossy().to_string()) - .collect(); - info!( - command = format!("{} {}", lp.display(), args.join(" ")), - "🖨️ Выполнение команды печати" - ); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect(); + info!(command = format!("{} {}", lp.display(), args.join(" ")), "🖨️ Команда печати"); - let out = cmd - .output() - .map_err(|e| format!("Не удалось запустить lp: {}", e))?; + let out = cmd.output().map_err(|e| format!("Не удалось запустить lp: {}", e))?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } - // stdout: "request id is PRINTER-123 (1 file(s))" let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - Ok(stdout - .split_whitespace() - .nth(3) - .unwrap_or("") - .to_string()) + Ok(stdout.split_whitespace().nth(3).unwrap_or("").to_string()) } fn lpstat_raw(which: &str) -> Option {