diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c71c537..83e5a5c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -346,7 +346,7 @@ pub async fn run_profcheck( pub async fn get_windows_printers() -> Result, String> { #[cfg(windows)] { - crate::print::windows::get_printers() + crate::print::windows::get_printers().map(|list| list.into_iter().map(|p| p.name).collect()) } #[cfg(not(windows))] { @@ -400,6 +400,48 @@ pub async fn print_target_cups( } } +#[tauri::command] +pub async fn get_printers() -> Result, String> { + #[cfg(windows)] + { + crate::print::windows::get_printers() + } + #[cfg(unix)] + { + crate::print::unix::get_printers() + } + #[cfg(not(any(windows, unix)))] + { + Err("Native printer enumeration is not supported on this platform.".to_string()) + } +} + +#[tauri::command] +pub async fn print_target_native( + printer_name: String, + tiff_path: String, + ppd_uncorrected_passthrough: Option, +) -> Result<(), String> { + #[cfg(windows)] + { + let _ = ppd_uncorrected_passthrough; + crate::print::windows::print_target(&printer_name, &tiff_path) + } + #[cfg(unix)] + { + crate::print::unix::print_target( + &printer_name, + &tiff_path, + ppd_uncorrected_passthrough.unwrap_or(false), + ) + } + #[cfg(not(any(windows, unix)))] + { + let _ = (printer_name, tiff_path, ppd_uncorrected_passthrough); + Err("Native raw printing is not supported on this platform.".to_string()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 132a6ed..2ea6023 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,8 @@ pub fn run() { commands::print_target_windows, commands::get_cups_printers, commands::print_target_cups, + commands::get_printers, + commands::print_target_native, settings::load_settings, settings::save_settings, ]) diff --git a/src-tauri/src/print/windows.rs b/src-tauri/src/print/windows.rs index efcf261..b930b2a 100644 --- a/src-tauri/src/print/windows.rs +++ b/src-tauri/src/print/windows.rs @@ -15,12 +15,14 @@ use windows::Win32::Graphics::Printing::{ PRINTER_ENUM_LOCAL, PRINTER_INFO_2W, PRINTER_INFO_4W, }; +use crate::print::Printer; + fn to_wide(s: &str) -> Vec { OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect() } /// Enumerate available printers on the system -pub fn get_printers() -> Result, String> { +pub fn get_printers() -> Result, String> { unsafe { let flags = PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS; let mut bytes_needed = 0u32; @@ -74,7 +76,11 @@ pub fn get_printers() -> Result, String> { let info = *p_info.add(i); if !info.pPrinterName.is_null() { let name = info.pPrinterName.to_string().map_err(|e| e.to_string())?; - printers.push(name); + printers.push(Printer { + name, + status: "Ready".to_string(), + is_default: false, + }); } } return Ok(printers); @@ -101,7 +107,11 @@ pub fn get_printers() -> Result, String> { let info = *p_info.add(i); if !info.pPrinterName.is_null() { let name = info.pPrinterName.to_string().map_err(|e| e.to_string())?; - printers.push(name); + printers.push(Printer { + name, + status: "Ready".to_string(), + is_default: false, + }); } } Ok(printers) diff --git a/src/index.html b/src/index.html index a936cc2..ed939e3 100644 --- a/src/index.html +++ b/src/index.html @@ -190,6 +190,56 @@ + + + diff --git a/src/js/printtarg.js b/src/js/printtarg.js index b940dc8..a492908 100644 --- a/src/js/printtarg.js +++ b/src/js/printtarg.js @@ -5,6 +5,8 @@ import { setStage2Result } from './chartread.js'; // Module-level state: set by Stage 1 when it completes let stage1Basename = ""; let stage1Cwd = ""; +let currentManifest = null; +let discoveredPrinters = []; /** * Called by targen.js (or app.js) after Stage 1 completes. @@ -30,6 +32,18 @@ export function initPrinttarg() { const galleryInfo = document.getElementById("galleryInfo"); const galleryGrid = document.getElementById("galleryGrid"); + // Raw Printing UI elements + const rawPrintPanel = document.getElementById("rawPrintPanel"); + const printerSelect = document.getElementById("printerSelect"); + const btnRefreshPrinters = document.getElementById("btnRefreshPrinters"); + const printerStatusBadge = document.getElementById("printerStatusBadge"); + const chkPpdFallback = document.getElementById("chkPpdFallback"); + const btnPrintAll = document.getElementById("btnPrintAll"); + const btnAdvanceToStage3 = document.getElementById("btnAdvanceToStage3"); + const printNotification = document.getElementById("printNotification"); + const printNotificationIcon = document.getElementById("printNotificationIcon"); + const printNotificationText = document.getElementById("printNotificationText"); + // Show/hide custom page size inputs pageSizeSelect.addEventListener("change", (e) => { if (e.target.value === "custom") { @@ -39,7 +53,126 @@ export function initPrinttarg() { } }); - // Create Layout button + /** + * Display status feedback banners in the raw printing panel. + */ + function showNotification(type, message) { + if (!printNotification) return; + printNotification.className = `notification-banner ${type}`; + printNotification.classList.remove("hidden"); + + if (printNotificationIcon) { + if (type === "success") printNotificationIcon.textContent = "✓"; + else if (type === "error") printNotificationIcon.textContent = "✕"; + else printNotificationIcon.textContent = "â„šī¸"; + } + + if (printNotificationText) { + printNotificationText.textContent = message; + } + } + + function hideNotification() { + if (printNotification) { + printNotification.classList.add("hidden"); + } + } + + /** + * Update the badge reflecting current printer's status. + */ + function updatePrinterStatusBadge() { + const selectedName = printerSelect.value; + const printer = discoveredPrinters.find(p => p.name === selectedName); + + if (!printer || !printerStatusBadge) { + if (printerStatusBadge) printerStatusBadge.classList.add("hidden"); + return; + } + + printerStatusBadge.classList.remove("hidden", "badge-idle", "badge-ready", "badge-printing", "badge-stopped", "badge-error", "badge-default"); + + const statusLower = (printer.status || "").toLowerCase(); + printerStatusBadge.textContent = printer.status || "Ready"; + + if (statusLower.includes("print")) { + printerStatusBadge.classList.add("badge-printing"); + } else if (statusLower.includes("stop") || statusLower.includes("disable") || statusLower.includes("error")) { + printerStatusBadge.classList.add("badge-stopped"); + } else if (statusLower.includes("idle") || statusLower.includes("ready")) { + printerStatusBadge.classList.add("badge-idle"); + } else { + printerStatusBadge.classList.add("badge-default"); + } + } + + /** + * Discover and enumerate all OS-installed printers via Tauri IPC. + */ + async function loadPrinters() { + if (!printerSelect) return; + printerSelect.disabled = true; + printerSelect.innerHTML = ''; + if (printerStatusBadge) printerStatusBadge.classList.add("hidden"); + + try { + const printers = await invoke("get_printers"); + discoveredPrinters = Array.isArray(printers) ? printers : []; + printerSelect.innerHTML = ""; + + if (discoveredPrinters.length === 0) { + printerSelect.innerHTML = ''; + printerSelect.disabled = true; + if (btnPrintAll) btnPrintAll.disabled = true; + return; + } + + printerSelect.disabled = false; + let defaultSelected = false; + + discoveredPrinters.forEach((p, idx) => { + const opt = document.createElement("option"); + opt.value = p.name; + opt.textContent = `${p.name}${p.is_default ? ' (Default)' : ''} [${p.status || 'Ready'}]`; + if (p.is_default && !defaultSelected) { + opt.selected = true; + defaultSelected = true; + } + printerSelect.appendChild(opt); + }); + + if (!defaultSelected && discoveredPrinters.length > 0) { + printerSelect.selectedIndex = 0; + } + + if (btnPrintAll && currentManifest) btnPrintAll.disabled = false; + updatePrinterStatusBadge(); + + } catch (err) { + console.error("[ICCery Print] Failed to query printers:", err); + printerSelect.innerHTML = ``; + printerSelect.disabled = true; + if (btnPrintAll) btnPrintAll.disabled = true; + showNotification("error", `Could not enumerate system printers: ${err}`); + } + } + + if (printerSelect) { + printerSelect.addEventListener("change", () => { + updatePrinterStatusBadge(); + }); + } + + if (btnRefreshPrinters) { + btnRefreshPrinters.addEventListener("click", () => { + loadPrinters(); + }); + } + + // Load initial printer list on startup + loadPrinters(); + + // Create Layout button handler btnCreateLayout.addEventListener("click", async () => { // Validate that Stage 1 has been completed if (!stage1Basename) { @@ -51,6 +184,8 @@ export function initPrinttarg() { logPre.textContent = ""; logContainer.classList.remove("hidden"); tiffGallery.classList.add("hidden"); + if (rawPrintPanel) rawPrintPanel.classList.add("hidden"); + hideNotification(); galleryGrid.innerHTML = ""; btnCreateLayout.disabled = true; @@ -83,8 +218,6 @@ export function initPrinttarg() { }; const processId = `printtarg_${stage1Basename}`; - - // Accumulate all stdout lines to extract JSON manifest at the end let stdoutAccumulator = ""; try { @@ -116,11 +249,14 @@ export function initPrinttarg() { // Parse the JSON manifest from stdout const manifest = extractManifest(stdoutAccumulator); if (manifest && manifest.pages && manifest.pages.length > 0) { + currentManifest = manifest; renderTiffGallery(manifest, config.cwd); + if (rawPrintPanel) rawPrintPanel.classList.remove("hidden"); + if (discoveredPrinters.length > 0 && btnPrintAll) btnPrintAll.disabled = false; + showNotification("info", "Target pages generated. Select your destination printer below and print with color management strictly bypassed."); } setStage2Result(stage1Basename, stage1Cwd); - advanceToStage3(); } else { logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`; btnCreateLayout.disabled = false; @@ -138,13 +274,115 @@ export function initPrinttarg() { }); /** - * Extract the JSON manifest object from the accumulated stdout. - * The manifest is emitted by printtarg -u as a JSON block starting with { and ending with }. - * It always appears at the end of stdout, after all "Creating file..." lines. + * Spool a single TIFF target file with native color management bypass. + */ + async function printTargetFile(filePath, label, triggeringButton) { + const printerName = printerSelect ? printerSelect.value : ""; + if (!printerName) { + showNotification("error", "Please select a destination printer first."); + return; + } + + const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false; + const origBtnContent = triggeringButton ? triggeringButton.innerHTML : ""; + + if (triggeringButton) { + triggeringButton.disabled = true; + triggeringButton.innerHTML = ' Spooling...'; + } + + showNotification("info", `Sending ${label} to '${printerName}' (bypassing color management)...`); + + try { + await invoke("print_target_native", { + printerName, + tiffPath: filePath, + ppdUncorrectedPassthrough: ppdFallback, + }); + + showNotification("success", `✓ Successfully spooled ${label} to '${printerName}' with raw color management bypass.`); + } catch (err) { + console.error("[ICCery Print Error]:", err); + showNotification("error", `Print job failed for ${label}: ${err}`); + } finally { + if (triggeringButton) { + triggeringButton.disabled = false; + triggeringButton.innerHTML = origBtnContent; + } + } + } + + /** + * Print all generated TIFF target pages in sequence. + */ + if (btnPrintAll) { + btnPrintAll.addEventListener("click", async () => { + if (!currentManifest || !currentManifest.pages || currentManifest.pages.length === 0) { + showNotification("error", "No generated target pages available to print."); + return; + } + + const printerName = printerSelect ? printerSelect.value : ""; + if (!printerName) { + showNotification("error", "Please select a destination printer first."); + return; + } + + const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false; + const cwd = stage1Cwd; + const sep = cwd.includes('\\') ? '\\' : '/'; + const pages = currentManifest.pages; + + btnPrintAll.disabled = true; + const btnText = btnPrintAll.querySelector(".btn-text"); + const btnSpinner = btnPrintAll.querySelector(".btn-spinner"); + if (btnSpinner) btnSpinner.classList.remove("hidden"); + if (btnText) btnText.textContent = "Spooling Targets..."; + + let errorCount = 0; + + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + const filePath = cwd ? `${cwd}${sep}${page.filename}` : page.filename; + showNotification("info", `Spooling page ${i + 1} of ${pages.length} (${page.filename}) to '${printerName}'...`); + + try { + await invoke("print_target_native", { + printerName, + tiffPath: filePath, + ppdUncorrectedPassthrough: ppdFallback, + }); + } catch (err) { + console.error(`[ICCery Print Error] Page ${page.filename}:`, err); + showNotification("error", `Failed on page ${page.filename}: ${err}`); + errorCount++; + break; + } + } + + if (errorCount === 0) { + showNotification("success", `✓ All ${pages.length} target page(s) successfully spooled to '${printerName}'!`); + } + + btnPrintAll.disabled = false; + if (btnSpinner) btnSpinner.classList.add("hidden"); + if (btnText) btnText.textContent = "đŸ–¨ī¸ Print All Pages (Bypass CM)"; + }); + } + + // Proceed to Stage 3 + if (btnAdvanceToStage3) { + btnAdvanceToStage3.addEventListener("click", () => { + setStage2Result(stage1Basename, stage1Cwd); + advanceToStage3(); + }); + } + + /** + * Extract JSON manifest emitted by printtarg -u. */ function extractManifest(stdout) { try { - // Find the last JSON object in the output const jsonStart = stdout.lastIndexOf('{\n "event": "manifest"'); if (jsonStart === -1) return null; const jsonEnd = stdout.indexOf('\n}', jsonStart); @@ -158,9 +396,7 @@ export function initPrinttarg() { } /** - * Render TIFF preview gallery using base64-encoded images from the backend. - * @param {Object} manifest - The parsed JSON manifest from printtarg -u - * @param {string} cwd - The working directory where TIFFs were generated + * Render TIFF preview gallery with individual card print actions. */ async function renderTiffGallery(manifest, cwd) { tiffGallery.classList.remove("hidden"); @@ -169,6 +405,7 @@ export function initPrinttarg() { const totalPatches = manifest.pages.reduce((sum, p) => sum + p.patches, 0); const dims = manifest.pages[0]; galleryInfo.textContent = `${pageCount} page(s) ¡ ${totalPatches} patches ¡ ${dims.width_mm} × ${dims.height_mm} mm per page`; + galleryGrid.innerHTML = ""; for (const page of manifest.pages) { const sep = cwd.includes('\\') ? '\\' : '/'; @@ -187,7 +424,6 @@ export function initPrinttarg() { const img = document.createElement("img"); img.src = `data:image/tiff;base64,${base64Data}`; img.alt = page.filename; - // TIFF may not render natively in all browsers — provide a fallback img.onerror = () => { img.remove(); const fallback = document.createElement("div"); @@ -203,6 +439,19 @@ export function initPrinttarg() { card.appendChild(fallback); } + // Individual Card Print Button + const cardActions = document.createElement("div"); + cardActions.className = "gallery-card-actions"; + const btnPrintCard = document.createElement("button"); + btnPrintCard.type = "button"; + btnPrintCard.className = "btn-print-page"; + btnPrintCard.innerHTML = "đŸ–¨ī¸ Print Page"; + btnPrintCard.addEventListener("click", () => { + printTargetFile(filePath, page.filename, btnPrintCard); + }); + cardActions.appendChild(btnPrintCard); + card.appendChild(cardActions); + galleryGrid.appendChild(card); } } diff --git a/src/styles/main.css b/src/styles/main.css index f2992e5..117206f 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -847,3 +847,229 @@ button.danger:hover { border-top: 1px solid var(--border-color); margin: 20px 0; } + +/* ======================================== + Stage 2: Native Raw Printing Engine Panel + ======================================== */ +.print-panel { + margin-top: 24px; + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 18px 20px; + animation: fadeIn 0.3s ease; +} + +.print-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.print-panel-title { + display: flex; + align-items: center; + gap: 8px; +} + +.print-panel-title h3 { + margin: 0; + color: #fff; + font-size: 1.1rem; +} + +.acpu-badge { + background: rgba(0, 122, 204, 0.15); + border: 1px solid rgba(0, 122, 204, 0.4); + color: #4fc3f7; + font-size: 0.75rem; + font-weight: 600; + padding: 3px 8px; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.print-panel-desc { + margin: 0 0 16px 0; + font-size: 0.85rem; + color: #aaa; + line-height: 1.4; +} + +.printer-controls-row { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: flex-end; + margin-bottom: 18px; +} + +.printer-select-group { + flex: 1; + min-width: 280px; + margin-bottom: 0; +} + +.printer-select-wrapper { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +.printer-select-wrapper select { + flex: 1; + max-width: 100%; +} + +.btn-icon { + background: var(--bg-color); + border: 1px solid var(--border-color); + color: var(--text-color); + width: 34px; + height: 34px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s; + flex-shrink: 0; +} + +.btn-icon:hover { + border-color: var(--accent-color); + color: #fff; +} + +.status-badge { + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 10px; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} + +.badge-idle, .badge-ready { + background: rgba(76, 175, 80, 0.15); + color: #81c784; + border: 1px solid rgba(76, 175, 80, 0.3); +} + +.badge-printing { + background: rgba(33, 150, 243, 0.15); + color: #64b5f6; + border: 1px solid rgba(33, 150, 243, 0.3); +} + +.badge-stopped, .badge-error { + background: rgba(244, 67, 54, 0.15); + color: #e57373; + border: 1px solid rgba(244, 67, 54, 0.3); +} + +.badge-default { + background: rgba(255, 193, 7, 0.15); + color: #ffd54f; + border: 1px solid rgba(255, 193, 7, 0.3); +} + +.print-options-group { + margin-bottom: 0; + display: flex; + align-items: center; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: #ccc; + cursor: pointer; + user-select: none; +} + +.notification-banner { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; + border-radius: 6px; + font-size: 0.85rem; + margin-bottom: 16px; + animation: fadeIn 0.2s ease; +} + +.notification-banner.info { + background: rgba(33, 150, 243, 0.12); + border: 1px solid rgba(33, 150, 243, 0.3); + color: #90caf9; +} + +.notification-banner.success { + background: rgba(76, 175, 80, 0.12); + border: 1px solid rgba(76, 175, 80, 0.3); + color: #a5d6a7; +} + +.notification-banner.error { + background: rgba(244, 67, 54, 0.12); + border: 1px solid rgba(244, 67, 54, 0.3); + color: #ef9a9a; +} + +.print-actions-row { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; + margin-top: 12px; +} + +.gallery-card-actions { + padding: 8px 12px; + background: rgba(0, 0, 0, 0.15); + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; +} + +.btn-print-page { + font-size: 0.8rem; + padding: 5px 10px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--border-color); + color: var(--text-color); + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.2s; +} + +.btn-print-page:hover { + background: var(--accent-color); + border-color: var(--accent-color); + color: #fff; +} + +.btn-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #fff; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} +