From f3067a60105d02271eb42c2c420811ff2c1ce9b4 Mon Sep 17 00:00:00 2001 From: gronod Date: Sat, 22 Aug 2026 17:38:21 +0100 Subject: [PATCH] feat: Stage 2 - Target Image Layout & printtarg Integration --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 100 +++++++++++++++++ src-tauri/src/lib.rs | 2 + src/index.html | 90 ++++++++++++++- src/js/app.js | 4 + src/js/printtarg.js | 224 ++++++++++++++++++++++++++++++++++++++ src/js/targen.js | 2 + src/styles/main.css | 109 ++++++++++++++++++- 9 files changed, 530 insertions(+), 3 deletions(-) create mode 100644 src/js/printtarg.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 61cd4d4..0ab1ad3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1579,6 +1579,7 @@ dependencies = [ name = "iccery" version = "0.1.0" dependencies = [ + "base64 0.22.1", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0ba1c25..060753c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } tauri-plugin-dialog = "2.7.2" +base64 = "0.22" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 314cdc1..b397e59 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -63,6 +63,16 @@ pub struct TargenConfig { pub cwd: String, } +#[derive(Debug, Deserialize, Serialize)] +pub struct PrinttargConfig { + pub instrument: String, // One of: "i1", "p3", "CM", "SS", "20", "22", "41", "51" + pub page_size: String, // One of: "A4", "A4R", "A3", "A2", "Letter", "LetterR", "Legal", "4x6", "11x17", or "WWWxHHH" + pub bit_depth: u8, // 8 or 16 + pub dpi: u32, // TIFF resolution, e.g. 100, 200, 300 + pub basename: String, // Must match the .ti1 basename from Stage 1 + pub cwd: String, // Working directory where the .ti1 file resides +} + pub fn build_targen_args(config: &TargenConfig) -> Vec { let mut args = vec![ "-v".to_string(), @@ -92,6 +102,27 @@ pub fn build_targen_args(config: &TargenConfig) -> Vec { args } +pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec { + let mut args = vec![ + "-v".to_string(), + "-u".to_string(), + "-i".to_string(), + config.instrument.clone(), + "-p".to_string(), + config.page_size.clone(), + ]; + + if config.bit_depth == 16 { + args.push("-T".to_string()); + } else { + args.push("-t".to_string()); + } + args.push(config.dpi.to_string()); + + args.push(config.basename.clone()); + args +} + #[tauri::command] pub async fn run_targen( app: AppHandle, @@ -113,6 +144,33 @@ pub async fn run_targen( state.spawn(app, id, binary, args, cwd).await } +#[tauri::command] +pub async fn run_printtarg( + app: AppHandle, + state: State<'_, ProcessManager>, + config: PrinttargConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "printtarg".to_string()).await?; + let args = build_printtarg_args(&config); + let id = format!("printtarg_{}", config.basename); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + +#[tauri::command] +pub async fn read_file_base64(path: String) -> Result { + use std::fs; + let bytes = fs::read(&path).map_err(|e| format!("Failed to read {}: {}", path, e))?; + use base64::Engine; + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + #[cfg(test)] mod tests { use super::*; @@ -144,4 +202,46 @@ mod tests { let args = build_targen_args(&config); assert_eq!(args, vec!["-v", "-d", "4", "-f", "1500", "-B", "8", "cmyk_profile"]); } + + #[test] + fn test_build_printtarg_args_i1_a4_8bit() { + let config = PrinttargConfig { + instrument: "i1".to_string(), + page_size: "A4".to_string(), + bit_depth: 8, + dpi: 100, + basename: "my_profile".to_string(), + cwd: "/tmp".to_string(), + }; + let args = build_printtarg_args(&config); + assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-t", "100", "my_profile"]); + } + + #[test] + fn test_build_printtarg_args_cm_letter_16bit() { + let config = PrinttargConfig { + instrument: "CM".to_string(), + page_size: "Letter".to_string(), + bit_depth: 16, + dpi: 300, + basename: "cmyk_profile".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_printtarg_args(&config); + assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-T", "300", "cmyk_profile"]); + } + + #[test] + fn test_build_printtarg_args_custom_page_size() { + let config = PrinttargConfig { + instrument: "SS".to_string(), + page_size: "200x400".to_string(), + bit_depth: 8, + dpi: 150, + basename: "custom_target".to_string(), + cwd: "/tmp".to_string(), + }; + let args = build_printtarg_args(&config); + assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d1f622..2c63096 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,8 @@ pub fn run() { commands::resolve_binary, commands::list_instruments, commands::run_targen, + commands::run_printtarg, + commands::read_file_base64, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/index.html b/src/index.html index c2aae67..3956978 100644 --- a/src/index.html +++ b/src/index.html @@ -84,8 +84,94 @@ diff --git a/src/js/app.js b/src/js/app.js index c9d7f1b..82723e6 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1,4 +1,5 @@ import { initTargen } from './targen.js'; +import { initPrinttarg } from './printtarg.js'; const { invoke } = window.__TAURI__.core; @@ -27,4 +28,7 @@ document.addEventListener('DOMContentLoaded', () => { // Initialize Stage 1 initTargen(); + + // Initialize Stage 2 + initPrinttarg(); }); diff --git a/src/js/printtarg.js b/src/js/printtarg.js new file mode 100644 index 0000000..da8d6db --- /dev/null +++ b/src/js/printtarg.js @@ -0,0 +1,224 @@ +const { invoke } = window.__TAURI__.core; +const { listen } = window.__TAURI__.event; + +// Module-level state: set by Stage 1 when it completes +let stage1Basename = ""; +let stage1Cwd = ""; + +/** + * Called by targen.js (or app.js) after Stage 1 completes. + * Passes the basename and working directory forward. + */ +export function setStage1Result(basename, cwd) { + stage1Basename = basename; + stage1Cwd = cwd; +} + +export function initPrinttarg() { + const instrumentSelect = document.getElementById("instrumentSelect"); + const pageSizeSelect = document.getElementById("pageSizeSelect"); + const customPageSizeRow = document.getElementById("customPageSizeRow"); + const customPageW = document.getElementById("customPageW"); + const customPageH = document.getElementById("customPageH"); + const bitDepthRadios = document.querySelectorAll('input[name="bitDepth"]'); + const tiffDpi = document.getElementById("tiffDpi"); + const btnCreateLayout = document.getElementById("btnCreateLayout"); + const logContainer = document.getElementById("printtargLogContainer"); + const logPre = document.getElementById("printtargLog"); + const tiffGallery = document.getElementById("tiffGallery"); + const galleryInfo = document.getElementById("galleryInfo"); + const galleryGrid = document.getElementById("galleryGrid"); + + // Show/hide custom page size inputs + pageSizeSelect.addEventListener("change", (e) => { + if (e.target.value === "custom") { + customPageSizeRow.classList.remove("hidden"); + } else { + customPageSizeRow.classList.add("hidden"); + } + }); + + // Create Layout button + btnCreateLayout.addEventListener("click", async () => { + // Validate that Stage 1 has been completed + if (!stage1Basename) { + logPre.textContent = "[ERROR] No .ti1 file available. Complete Stage 1 first.\n"; + logContainer.classList.remove("hidden"); + return; + } + + logPre.textContent = ""; + logContainer.classList.remove("hidden"); + tiffGallery.classList.add("hidden"); + galleryGrid.innerHTML = ""; + btnCreateLayout.disabled = true; + + // Determine page size + let pageSize = pageSizeSelect.value; + if (pageSize === "custom") { + const w = parseInt(customPageW.value, 10); + const h = parseInt(customPageH.value, 10); + if (!w || !h || w < 50 || h < 50) { + logPre.textContent = "[ERROR] Custom page size must have width and height ≥ 50mm.\n"; + btnCreateLayout.disabled = false; + return; + } + pageSize = `${w}x${h}`; + } + + // Determine bit depth + let bitDepth = 8; + bitDepthRadios.forEach(radio => { + if (radio.checked) bitDepth = parseInt(radio.value, 10); + }); + + const config = { + instrument: instrumentSelect.value, + page_size: pageSize, + bit_depth: bitDepth, + dpi: parseInt(tiffDpi.value, 10), + basename: stage1Basename, + cwd: stage1Cwd, + }; + + const processId = `printtarg_${stage1Basename}`; + + // Accumulate all stdout lines to extract JSON manifest at the end + let stdoutAccumulator = ""; + + try { + const unlistenStdout = await listen("process:stdout", (event) => { + if (event.payload.id === processId && event.payload.line) { + stdoutAccumulator += event.payload.line + "\n"; + logPre.textContent += event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenStderr = await listen("process:stderr", (event) => { + if (event.payload.id === processId && event.payload.line) { + logPre.textContent += "ERR: " + event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenExit = await listen("process:exit", (event) => { + if (event.payload.id === processId) { + unlistenStdout(); + unlistenStderr(); + unlistenExit(); + + if (event.payload.code === 0) { + logPre.textContent += "\n[SUCCESS] printtarg completed successfully.\n"; + btnCreateLayout.disabled = false; + + // Parse the JSON manifest from stdout + const manifest = extractManifest(stdoutAccumulator); + if (manifest && manifest.pages && manifest.pages.length > 0) { + renderTiffGallery(manifest, config.cwd); + } + + advanceToStage3(); + } else { + logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`; + btnCreateLayout.disabled = false; + } + } + }); + + logPre.textContent = "Starting printtarg...\n"; + await invoke("run_printtarg", { config }); + + } catch (err) { + logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; + btnCreateLayout.disabled = false; + } + }); + + /** + * 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. + */ + 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); + if (jsonEnd === -1) return null; + const jsonStr = stdout.substring(jsonStart, jsonEnd + 2); + return JSON.parse(jsonStr); + } catch (e) { + console.error("Failed to parse printtarg manifest:", e); + return null; + } + } + + /** + * 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 + */ + async function renderTiffGallery(manifest, cwd) { + tiffGallery.classList.remove("hidden"); + + const pageCount = manifest.pages.length; + 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`; + + for (const page of manifest.pages) { + const sep = cwd.includes('\\') ? '\\' : '/'; + const filePath = cwd ? `${cwd}${sep}${page.filename}` : page.filename; + + const card = document.createElement("div"); + card.className = "gallery-card"; + + const label = document.createElement("div"); + label.className = "gallery-label"; + label.textContent = `${page.filename} (${page.patches} patches)`; + card.appendChild(label); + + try { + const base64Data = await invoke("read_file_base64", { path: filePath }); + 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"); + fallback.className = "gallery-fallback"; + fallback.innerHTML = `📄${page.filename}${page.patches} patches`; + card.insertBefore(fallback, label.nextSibling); + }; + card.appendChild(img); + } catch (err) { + const fallback = document.createElement("div"); + fallback.className = "gallery-fallback"; + fallback.innerHTML = `📄${page.filename}Preview unavailable`; + card.appendChild(fallback); + } + + galleryGrid.appendChild(card); + } + } +} + +function advanceToStage3() { + const steps = document.querySelectorAll('.step'); + const stages = document.querySelectorAll('.stage'); + + steps.forEach(s => s.classList.remove('active')); + if (steps[2]) steps[2].classList.add('active'); + + stages.forEach(s => { + s.classList.remove('active'); + s.classList.add('hidden'); + }); + if (stages[2]) { + stages[2].classList.remove('hidden'); + stages[2].classList.add('active'); + } +} diff --git a/src/js/targen.js b/src/js/targen.js index 0b854e1..a6917cb 100644 --- a/src/js/targen.js +++ b/src/js/targen.js @@ -1,6 +1,7 @@ const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; const { save } = window.__TAURI__.dialog; +import { setStage1Result } from './printtarg.js'; export function initTargen() { const colourSpaceRadios = document.querySelectorAll('input[name="colourSpace"]'); @@ -131,6 +132,7 @@ export function initTargen() { // In a real app we'd dispatch an event to advance the stepper here. // For now, we'll manually unlock stage 2 in the state. btnGenerate.disabled = false; + setStage1Result(basename, currentWorkingDir); advanceToStage2(); } else { logPre.textContent += `\n[ERROR] Targen exited with code ${event.payload.code}.\n`; diff --git a/src/styles/main.css b/src/styles/main.css index b4b182b..82f9bde 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -225,7 +225,8 @@ button:disabled { font-size: 0.9em; } -#targenLog { +#targenLog, +#printtargLog { margin: 0; font-family: monospace; font-size: 0.9em; @@ -234,3 +235,109 @@ button:disabled { overflow-y: auto; white-space: pre-wrap; } + +/* ======================================== + Stage 2: Warning Banner + ======================================== */ +.warning-banner { + display: flex; + align-items: flex-start; + gap: 12px; + background: rgba(255, 152, 0, 0.12); + border: 1px solid rgba(255, 152, 0, 0.4); + border-left: 4px solid #ff9800; + border-radius: 6px; + padding: 16px; + margin-bottom: 20px; +} + +.warning-icon { + font-size: 1.5rem; + flex-shrink: 0; + line-height: 1; +} + +.warning-text strong { + display: block; + color: #ffb74d; + margin-bottom: 6px; +} + +.warning-text p { + margin: 0; + font-size: 0.9em; + color: #ccc; + line-height: 1.5; +} + +/* ======================================== + Stage 2: TIFF Gallery + ======================================== */ +.tiff-gallery { + margin-top: 24px; +} + +.tiff-gallery h3 { + margin: 0 0 8px 0; + color: #fff; +} + +.gallery-info { + font-size: 0.9em; + color: #888; + margin-bottom: 16px; +} + +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.gallery-card { + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.2s; +} + +.gallery-card:hover { + border-color: var(--accent-color); +} + +.gallery-card img { + width: 100%; + height: auto; + display: block; + background: #fff; +} + +.gallery-label { + padding: 10px 12px; + font-size: 0.85em; + color: #aaa; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gallery-fallback { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px 16px; + background: var(--bg-color); + gap: 8px; +} + +.gallery-fallback .file-icon { + font-size: 2.5rem; +} + +.gallery-fallback .file-size { + font-size: 0.8em; + color: #666; +} + -- 2.39.5