feat: Stage 2 - Target Image Layout & printtarg Integration #12

Merged
gronod merged 1 commits from feature/stage-2-printtarg into feature/stage-1-targen 2026-08-22 18:27:15 +01:00
9 changed files with 530 additions and 3 deletions
+1
View File
@@ -1579,6 +1579,7 @@ dependencies = [
name = "iccery"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"serde",
"serde_json",
"tauri",
+1
View File
@@ -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"
+100
View File
@@ -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<String> {
let mut args = vec![
"-v".to_string(),
@@ -92,6 +102,27 @@ pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
args
}
pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec<String> {
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<String, String> {
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"]);
}
}
+2
View File
@@ -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");
+88 -2
View File
@@ -84,8 +84,94 @@
<!-- Stage 2: printtarg -->
<section id="stage-2" class="stage hidden">
<h2>Print Layout</h2>
<p>Prepare the target for printing based on your instrument.</p>
<button class="primary" id="btnLayout">Create Layout (.ti2, .tif)</button>
<p>Configure the target layout for your instrument and paper size, then generate printable TIFF images.</p>
<!-- Colour management warning banner -->
<div class="warning-banner" id="cmWarningBanner">
<span class="warning-icon">⚠️</span>
<div class="warning-text">
<strong>CRITICAL: Disable ALL printer driver colour management when printing these targets.</strong>
<p>Set your printer driver to "No Colour Adjustment" (Epson), "Off (No Colour Adjustment)" (Canon), or the equivalent setting for your printer. Failure to do this will produce an incorrect profile.</p>
</div>
</div>
<div class="form-container">
<div class="form-group">
<label>Measurement Instrument</label>
<select id="instrumentSelect">
<option value="i1" selected>X-Rite i1Pro / i1Pro2</option>
<option value="p3">X-Rite i1Pro3+</option>
<option value="CM">X-Rite ColorMunki</option>
<option value="SS">X-Rite SpectroScan</option>
<option value="20">X-Rite DTP20</option>
<option value="22">X-Rite DTP22</option>
<option value="41">X-Rite DTP41</option>
<option value="51">X-Rite DTP51</option>
</select>
</div>
<div class="form-group">
<label>Page Size</label>
<select id="pageSizeSelect">
<option value="A4" selected>A4 [210 × 297 mm]</option>
<option value="A4R">A4 Landscape [297 × 210 mm]</option>
<option value="A3">A3 [297 × 420 mm]</option>
<option value="A2">A2 [420 × 594 mm]</option>
<option value="Letter">US Letter [215.9 × 279.4 mm]</option>
<option value="LetterR">US Letter Landscape [279.4 × 215.9 mm]</option>
<option value="Legal">US Legal [215.9 × 355.6 mm]</option>
<option value="4x6">4×6 [101.6 × 152.4 mm]</option>
<option value="11x17">11×17 [279.4 × 431.8 mm]</option>
<option value="custom">Custom...</option>
</select>
<div id="customPageSizeRow" class="input-row hidden" style="margin-top: 8px;">
<div>
<label for="customPageW" class="sub-label">Width (mm)</label>
<input type="number" id="customPageW" min="50" max="2000" placeholder="e.g. 200">
</div>
<div>
<label for="customPageH" class="sub-label">Height (mm)</label>
<input type="number" id="customPageH" min="50" max="2000" placeholder="e.g. 400">
</div>
</div>
</div>
<div class="form-group">
<label>TIFF Output</label>
<div class="input-row">
<div>
<label class="sub-label">Bit Depth</label>
<div class="radio-group">
<label><input type="radio" name="bitDepth" value="8" checked> 8-bit</label>
<label><input type="radio" name="bitDepth" value="16"> 16-bit</label>
</div>
</div>
<div>
<label for="tiffDpi" class="sub-label">Resolution (DPI)</label>
<select id="tiffDpi">
<option value="100">100 DPI (Draft)</option>
<option value="200" selected>200 DPI (Standard)</option>
<option value="300">300 DPI (High Quality)</option>
<option value="600">600 DPI (Ultra)</option>
</select>
</div>
</div>
</div>
</div>
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
<div class="log-container hidden" id="printtargLogContainer">
<h4>Process Output</h4>
<pre id="printtargLog"></pre>
</div>
<!-- TIFF preview gallery (populated dynamically after generation) -->
<div class="tiff-gallery hidden" id="tiffGallery">
<h3>Generated Target Pages</h3>
<div class="gallery-info" id="galleryInfo"></div>
<div class="gallery-grid" id="galleryGrid"></div>
</div>
</section>
<!-- Stage 3: chartread -->
+4
View File
@@ -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();
});
+224
View File
@@ -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 = `<span class="file-icon">📄</span><span>${page.filename}</span><span class="file-size">${page.patches} patches</span>`;
card.insertBefore(fallback, label.nextSibling);
};
card.appendChild(img);
} catch (err) {
const fallback = document.createElement("div");
fallback.className = "gallery-fallback";
fallback.innerHTML = `<span class="file-icon">📄</span><span>${page.filename}</span><span class="file-size">Preview unavailable</span>`;
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');
}
}
+2
View File
@@ -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`;
+108 -1
View File
@@ -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;
}