feat(presets): Reusable Profiling Presets with import/export and multi-stage loading (fixes #88) #100

Merged
gronod merged 5 commits from feat/88-profiling-presets into development 2026-08-25 17:58:50 +01:00
5 changed files with 539 additions and 0 deletions
+5
View File
@@ -35,6 +35,11 @@ pub fn run() {
commands::print_target_native, commands::print_target_native,
settings::load_settings, settings::load_settings,
settings::save_settings, settings::save_settings,
settings::get_all_presets,
settings::save_preset,
settings::delete_preset,
settings::export_preset_json,
settings::import_preset_json,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+167
View File
@@ -2,10 +2,99 @@ use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct ProfilingPreset {
pub id: String,
pub name: String,
pub description: Option<String>,
pub colour_space: String,
pub patch_count: u32,
pub white_patches: Option<u32>,
pub black_patches: Option<u32>,
pub instrument: String,
pub page_size: String,
pub bit_depth: u8,
pub dpi: u32,
pub colprof_algorithm: String,
pub colprof_quality: String,
pub colprof_intent: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)] #[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct AppSettings { pub struct AppSettings {
pub argyll_binary_dir: Option<String>, pub argyll_binary_dir: Option<String>,
pub default_instrument: Option<String>, pub default_instrument: Option<String>,
#[serde(default)]
pub custom_presets: Vec<ProfilingPreset>,
}
pub fn get_default_presets() -> Vec<ProfilingPreset> {
vec![
ProfilingPreset {
id: "preset-std-rgb".to_string(),
name: "Standard RGB Photo (800 patches)".to_string(),
description: Some("Recommended for standard fine-art and photographic RGB printing on inkjet printers.".to_string()),
colour_space: "rgb".to_string(),
patch_count: 800,
white_patches: Some(4),
black_patches: None,
instrument: "i1".to_string(),
page_size: "A4".to_string(),
bit_depth: 8,
dpi: 300,
colprof_algorithm: "l".to_string(),
colprof_quality: "m".to_string(),
colprof_intent: None,
},
ProfilingPreset {
id: "preset-hq-cmyk".to_string(),
name: "High-Gamut CMYK Proofing (1500 patches)".to_string(),
description: Some("High-precision CMYK press and RIP proofing profiling with deep shadow neutral boost.".to_string()),
colour_space: "cmyk".to_string(),
patch_count: 1500,
white_patches: None,
black_patches: Some(8),
instrument: "i1".to_string(),
page_size: "A3".to_string(),
bit_depth: 16,
dpi: 300,
colprof_algorithm: "l".to_string(),
colprof_quality: "h".to_string(),
colprof_intent: None,
},
ProfilingPreset {
id: "preset-draft-rgb".to_string(),
name: "Fast RGB Draft (400 patches)".to_string(),
description: Some("Quick turn-around draft profiling for testing new media.".to_string()),
colour_space: "rgb".to_string(),
patch_count: 400,
white_patches: None,
black_patches: None,
instrument: "i1".to_string(),
page_size: "A4".to_string(),
bit_depth: 8,
dpi: 150,
colprof_algorithm: "l".to_string(),
colprof_quality: "l".to_string(),
colprof_intent: None,
},
ProfilingPreset {
id: "preset-ultra-rgb".to_string(),
name: "Ultra Precision RGB (2500 patches)".to_string(),
description: Some("Maximum precision lookup table profile for exhibition and master printmaking.".to_string()),
colour_space: "rgb".to_string(),
patch_count: 2500,
white_patches: Some(6),
black_patches: Some(6),
instrument: "i1".to_string(),
page_size: "A3".to_string(),
bit_depth: 16,
dpi: 300,
colprof_algorithm: "l".to_string(),
colprof_quality: "u".to_string(),
colprof_intent: None,
},
]
} }
#[tauri::command] #[tauri::command]
@@ -25,3 +114,81 @@ pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String
let json = serde_json::to_string_pretty(&settings).unwrap(); let json = serde_json::to_string_pretty(&settings).unwrap();
fs::write(path.join("settings.json"), json).map_err(|e| e.to_string()) fs::write(path.join("settings.json"), json).map_err(|e| e.to_string())
} }
#[tauri::command]
pub fn get_all_presets(app: AppHandle) -> Vec<ProfilingPreset> {
let mut presets = get_default_presets();
let settings = load_settings(app).unwrap_or_default();
presets.extend(settings.custom_presets);
presets
}
#[tauri::command]
pub fn save_preset(app: AppHandle, preset: ProfilingPreset) -> Result<Vec<ProfilingPreset>, String> {
let mut settings = load_settings(app.clone()).unwrap_or_default();
if let Some(pos) = settings.custom_presets.iter().position(|p| p.id == preset.id) {
settings.custom_presets[pos] = preset;
} else {
settings.custom_presets.push(preset);
}
save_settings(app.clone(), settings)?;
Ok(get_all_presets(app))
}
#[tauri::command]
pub fn delete_preset(app: AppHandle, id: String) -> Result<Vec<ProfilingPreset>, String> {
let mut settings = load_settings(app.clone()).unwrap_or_default();
settings.custom_presets.retain(|p| p.id != id);
save_settings(app.clone(), settings)?;
Ok(get_all_presets(app))
}
#[tauri::command]
pub fn export_preset_json(preset: ProfilingPreset) -> Result<String, String> {
serde_json::to_string_pretty(&preset).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_preset_json(json: String) -> Result<ProfilingPreset, String> {
serde_json::from_str::<ProfilingPreset>(&json).map_err(|e| format!("Invalid preset format: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_default_presets() {
let defaults = get_default_presets();
assert_eq!(defaults.len(), 4);
assert_eq!(defaults[0].id, "preset-std-rgb");
assert_eq!(defaults[0].colour_space, "rgb");
assert_eq!(defaults[0].patch_count, 800);
assert_eq!(defaults[1].colour_space, "cmyk");
assert_eq!(defaults[1].patch_count, 1500);
}
#[test]
fn test_preset_json_export_and_import() {
let preset = ProfilingPreset {
id: "custom-test".to_string(),
name: "Custom Test Preset".to_string(),
description: Some("Test description".to_string()),
colour_space: "rgb".to_string(),
patch_count: 1200,
white_patches: Some(2),
black_patches: Some(4),
instrument: "i1".to_string(),
page_size: "A4".to_string(),
bit_depth: 16,
dpi: 300,
colprof_algorithm: "l".to_string(),
colprof_quality: "h".to_string(),
colprof_intent: None,
};
let json = export_preset_json(preset.clone()).expect("Export failed");
let imported = import_preset_json(json).expect("Import failed");
assert_eq!(preset, imported);
}
}
+53
View File
@@ -32,6 +32,21 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Profiling Preset Selector -->
<div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label for="presetSelect" style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600;">Profiling Preset</label>
<div style="display: flex; gap: 4px;">
<button type="button" id="btnSavePresetModal" class="icon-btn" title="Save current settings as Preset" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">💾</button>
<button type="button" id="btnOpenPresetsDialog" class="icon-btn" title="Manage Presets (Import/Export/Delete)" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">⚙️</button>
</div>
</div>
<select id="presetSelect" style="width: 100%; font-size: 0.85rem; padding: 6px 8px; border-radius: 6px; background: var(--bg-card, #1a1a22); color: var(--text-color, #eee); border: 1px solid var(--border-color, #333); cursor: pointer;">
<option value="" disabled selected>Loading presets...</option>
</select>
</div>
<nav class="stepper"> <nav class="stepper">
<button class="step active" data-step="1">1. Generate Target</button> <button class="step active" data-step="1">1. Generate Target</button>
<button class="step" data-step="2">2. Print Layout</button> <button class="step" data-step="2">2. Print Layout</button>
@@ -511,5 +526,43 @@
</div> </div>
</div> </div>
</dialog> </dialog>
<!-- Save Preset Dialog -->
<dialog id="savePresetDialog" class="settings-modal">
<div class="modal-content">
<h2>Save Profiling Preset</h2>
<p style="font-size: 0.9rem; opacity: 0.8; margin-bottom: 16px;">Save the current settings from Stage 1, 2, and 4 as a reusable profiling preset.</p>
<div class="form-group">
<label for="savePresetName">Preset Name</label>
<input type="text" id="savePresetName" placeholder="e.g. My Custom FineArt Preset">
</div>
<div class="form-group">
<label for="savePresetDesc">Description (Optional)</label>
<input type="text" id="savePresetDesc" placeholder="e.g. Optimized for 300gsm Cotton Rag with 1500 patches">
</div>
<div class="modal-actions">
<button id="btnConfirmSavePreset" class="primary">Save Preset</button>
<button id="btnCloseSavePresetDialog" class="secondary">Cancel</button>
</div>
</div>
</dialog>
<!-- Manage Presets Dialog -->
<dialog id="managePresetsDialog" class="settings-modal" style="max-width: 600px;">
<div class="modal-content">
<h2>Manage Profiling Presets</h2>
<p style="font-size: 0.9rem; opacity: 0.8; margin-bottom: 14px;">View built-in and custom presets, or import and export preset configurations in JSON format.</p>
<div id="managePresetsList" style="display: flex; flex-direction: column; gap: 8px; max-height: 240px; overflow-y: auto; margin-bottom: 16px; padding: 4px;"></div>
<div style="display: flex; gap: 8px; justify-content: space-between; border-top: 1px solid var(--border-color, #333); padding-top: 14px;">
<div style="display: flex; gap: 8px;">
<button type="button" id="btnExportActivePreset" class="secondary" title="Export currently selected preset to JSON file">📤 Export Active</button>
<button type="button" id="btnImportPreset" class="secondary" title="Import preset from JSON file">📥 Import Preset</button>
</div>
<button id="btnCloseManagePresetsDialog" class="primary">Done</button>
</div>
</div>
</dialog>
</body> </body>
</html> </html>
+2
View File
@@ -5,6 +5,7 @@ import { initColprof } from './colprof.js';
import { initProfcheck } from './profcheck.js'; import { initProfcheck } from './profcheck.js';
import { initSettings } from './settings.js'; import { initSettings } from './settings.js';
import { initGamutViewer } from './gamut_viewer.js'; import { initGamutViewer } from './gamut_viewer.js';
import { initPresets } from './presets.js';
import { wizardState } from './state.js'; import { wizardState } from './state.js';
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
@@ -89,4 +90,5 @@ document.addEventListener('DOMContentLoaded', () => {
safeInit('Stage 5 (Profcheck)', initProfcheck); safeInit('Stage 5 (Profcheck)', initProfcheck);
safeInit('Settings', initSettings); safeInit('Settings', initSettings);
safeInit('Gamut Viewer', initGamutViewer); safeInit('Gamut Viewer', initGamutViewer);
safeInit('Presets', initPresets);
}); });
+312
View File
@@ -0,0 +1,312 @@
const { invoke } = window.__TAURI__.core;
let currentPresets = [];
let activePresetId = "preset-std-rgb";
export async function initPresets() {
const presetSelect = document.getElementById("presetSelect");
const btnSavePresetModal = document.getElementById("btnSavePresetModal");
const btnOpenPresetsDialog = document.getElementById("btnOpenPresetsDialog");
const savePresetDialog = document.getElementById("savePresetDialog");
const btnCloseSavePresetDialog = document.getElementById("btnCloseSavePresetDialog");
const btnConfirmSavePreset = document.getElementById("btnConfirmSavePreset");
const savePresetName = document.getElementById("savePresetName");
const savePresetDesc = document.getElementById("savePresetDesc");
const managePresetsDialog = document.getElementById("managePresetsDialog");
const btnCloseManagePresetsDialog = document.getElementById("btnCloseManagePresetsDialog");
const managePresetsList = document.getElementById("managePresetsList");
const btnExportActivePreset = document.getElementById("btnExportActivePreset");
const btnImportPreset = document.getElementById("btnImportPreset");
async function loadPresets() {
try {
currentPresets = await invoke("get_all_presets");
renderPresetDropdown();
renderManagePresetsList();
} catch (err) {
console.error("Failed to load presets:", err);
}
}
function renderPresetDropdown() {
if (!presetSelect) return;
presetSelect.innerHTML = "";
currentPresets.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = p.name;
if (p.id === activePresetId) opt.selected = true;
presetSelect.appendChild(opt);
});
}
function renderManagePresetsList() {
if (!managePresetsList) return;
managePresetsList.innerHTML = "";
currentPresets.forEach((p) => {
const isBuiltin = p.id.startsWith("preset-");
const item = document.createElement("div");
item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:8px 12px; border-radius:6px;";
item.innerHTML = `
<div style="flex:1; margin-right:12px;">
<div style="font-weight:600; font-size:0.9rem;">${p.name} ${isBuiltin ? '<span style="font-size:0.7rem; opacity:0.6; border:1px solid #555; padding:1px 4px; border-radius:3px; margin-left:4px;">Built-in</span>' : ''}</div>
<div style="font-size:0.75rem; opacity:0.7; margin-top:2px;">${p.description || "No description"}</div>
<div style="font-size:0.7rem; opacity:0.5; margin-top:2px;">${p.colour_space.toUpperCase()}${p.patch_count} patches • ${p.page_size}${p.bit_depth}-bit • Quality ${p.colprof_quality.toUpperCase()}</div>
</div>
<div style="display:flex; gap:6px;">
<button type="button" class="secondary btn-export-one" data-id="${p.id}" style="font-size:0.75rem; padding:4px 8px;">Export</button>
${!isBuiltin ? `<button type="button" class="danger btn-delete-one" data-id="${p.id}" style="font-size:0.75rem; padding:4px 8px;">Delete</button>` : ''}
</div>
`;
managePresetsList.appendChild(item);
});
// Attach listeners for export / delete
managePresetsList.querySelectorAll(".btn-export-one").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.getAttribute("data-id");
const preset = currentPresets.find(p => p.id === id);
if (preset) exportPreset(preset);
});
});
managePresetsList.querySelectorAll(".btn-delete-one").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.getAttribute("data-id");
if (confirm("Delete this custom preset?")) {
try {
currentPresets = await invoke("delete_preset", { id });
if (activePresetId === id) activePresetId = currentPresets[0]?.id || "preset-std-rgb";
renderPresetDropdown();
renderManagePresetsList();
} catch (err) {
alert("Delete failed: " + err);
}
}
});
});
}
function applyPreset(preset) {
if (!preset) return;
activePresetId = preset.id;
// Stage 1 controls
const csRadios = document.querySelectorAll('input[name="colourSpace"]');
csRadios.forEach((r) => {
if (r.value.toLowerCase() === preset.colour_space.toLowerCase()) r.checked = true;
});
const patchCountPreset = document.getElementById("patchCountPreset");
const patchCountCustom = document.getElementById("patchCountCustom");
if (patchCountPreset && patchCountCustom) {
const match = Array.from(patchCountPreset.options).find(o => o.value === String(preset.patch_count));
if (match) {
patchCountPreset.value = String(preset.patch_count);
patchCountCustom.classList.add("hidden");
} else {
patchCountPreset.value = "custom";
patchCountCustom.value = preset.patch_count;
patchCountCustom.classList.remove("hidden");
}
}
const whitePatches = document.getElementById("whitePatches");
if (whitePatches) whitePatches.value = preset.white_patches !== null && preset.white_patches !== undefined ? preset.white_patches : "";
const blackPatches = document.getElementById("blackPatches");
if (blackPatches) blackPatches.value = preset.black_patches !== null && preset.black_patches !== undefined ? preset.black_patches : "";
// Stage 2 controls
const instrumentSelect = document.getElementById("instrumentSelect");
if (instrumentSelect && preset.instrument) instrumentSelect.value = preset.instrument;
const pageSizeSelect = document.getElementById("pageSizeSelect");
if (pageSizeSelect && preset.page_size) pageSizeSelect.value = preset.page_size;
const bitDepthRadios = document.querySelectorAll('input[name="bitDepth"]');
bitDepthRadios.forEach((r) => {
if (Number(r.value) === preset.bit_depth) r.checked = true;
});
// Stage 4 controls
const colprofQuality = document.getElementById("colprofQuality");
if (colprofQuality && preset.colprof_quality) colprofQuality.value = preset.colprof_quality;
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
if (colprofAlgorithm && preset.colprof_algorithm) colprofAlgorithm.value = preset.colprof_algorithm;
}
function collectCurrentSettingsAsPreset(name, description) {
const csRadio = document.querySelector('input[name="colourSpace"]:checked');
const colour_space = csRadio ? csRadio.value : "rgb";
const patchCountPreset = document.getElementById("patchCountPreset");
const patchCountCustom = document.getElementById("patchCountCustom");
let patch_count = 800;
if (patchCountPreset) {
if (patchCountPreset.value === "custom" && patchCountCustom) {
patch_count = parseInt(patchCountCustom.value, 10) || 800;
} else {
patch_count = parseInt(patchCountPreset.value, 10) || 800;
}
}
const whiteInput = document.getElementById("whitePatches");
const white_patches = whiteInput && whiteInput.value ? parseInt(whiteInput.value, 10) : null;
const blackInput = document.getElementById("blackPatches");
const black_patches = blackInput && blackInput.value ? parseInt(blackInput.value, 10) : null;
const instrumentSelect = document.getElementById("instrumentSelect");
const instrument = instrumentSelect ? instrumentSelect.value : "i1";
const pageSizeSelect = document.getElementById("pageSizeSelect");
const page_size = pageSizeSelect ? pageSizeSelect.value : "A4";
const bitDepthRadio = document.querySelector('input[name="bitDepth"]:checked');
const bit_depth = bitDepthRadio ? parseInt(bitDepthRadio.value, 10) : 8;
const colprofQuality = document.getElementById("colprofQuality");
const colprof_quality = colprofQuality ? colprofQuality.value : "m";
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
const colprof_algorithm = colprofAlgorithm ? colprofAlgorithm.value : "l";
return {
id: `custom-${Date.now()}`,
name: name || "Custom Preset",
description: description || null,
colour_space,
patch_count,
white_patches,
black_patches,
instrument,
page_size,
bit_depth,
dpi: 300,
colprof_algorithm,
colprof_quality,
colprof_intent: null,
};
}
function exportPreset(preset) {
try {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(preset, null, 2));
const downloadAnchor = document.createElement("a");
downloadAnchor.setAttribute("href", dataStr);
downloadAnchor.setAttribute("download", `${preset.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")}.preset.json`);
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
} catch (err) {
alert("Export failed: " + err);
}
}
// Dropdown change listener
if (presetSelect) {
presetSelect.addEventListener("change", () => {
const selected = currentPresets.find(p => p.id === presetSelect.value);
if (selected) applyPreset(selected);
});
}
// Save preset modal triggers
if (btnSavePresetModal && savePresetDialog) {
btnSavePresetModal.addEventListener("click", () => {
if (savePresetName) savePresetName.value = "";
if (savePresetDesc) savePresetDesc.value = "";
savePresetDialog.showModal();
});
}
if (btnCloseSavePresetDialog && savePresetDialog) {
btnCloseSavePresetDialog.addEventListener("click", () => {
savePresetDialog.close();
});
}
if (btnConfirmSavePreset && savePresetDialog) {
btnConfirmSavePreset.addEventListener("click", async () => {
const name = savePresetName ? savePresetName.value.trim() : "";
if (!name) {
alert("Please enter a name for your preset.");
return;
}
const desc = savePresetDesc ? savePresetDesc.value.trim() : "";
const preset = collectCurrentSettingsAsPreset(name, desc);
try {
currentPresets = await invoke("save_preset", { preset });
activePresetId = preset.id;
renderPresetDropdown();
renderManagePresetsList();
savePresetDialog.close();
} catch (err) {
alert("Save preset failed: " + err);
}
});
}
// Manage presets dialog triggers
if (btnOpenPresetsDialog && managePresetsDialog) {
btnOpenPresetsDialog.addEventListener("click", () => {
renderManagePresetsList();
managePresetsDialog.showModal();
});
}
if (btnCloseManagePresetsDialog && managePresetsDialog) {
btnCloseManagePresetsDialog.addEventListener("click", () => {
managePresetsDialog.close();
});
}
if (btnExportActivePreset) {
btnExportActivePreset.addEventListener("click", () => {
const preset = currentPresets.find(p => p.id === activePresetId);
if (preset) exportPreset(preset);
});
}
if (btnImportPreset) {
btnImportPreset.addEventListener("click", () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json";
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (evt) => {
try {
const jsonText = evt.target.result;
const imported = await invoke("import_preset_json", { json: jsonText });
imported.id = `custom-${Date.now()}`;
currentPresets = await invoke("save_preset", { preset: imported });
activePresetId = imported.id;
applyPreset(imported);
renderPresetDropdown();
renderManagePresetsList();
alert(`Preset "${imported.name}" imported successfully!`);
} catch (err) {
alert("Import failed: " + err);
}
};
reader.readAsText(file);
};
fileInput.click();
});
}
// Initial load
await loadPresets();
const defaultP = currentPresets.find(p => p.id === "preset-std-rgb");
if (defaultP) applyPreset(defaultP);
}