From 59e9a6a4f3caa014c1ca27e22e3705d956388333 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 22:56:28 -0700 Subject: [PATCH] LRC-PRINT-TEMPLATES: print templates export to a file and import from one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Print dialog's templates could be saved, applied and deleted, but not moved to another library or another computer. Each saved template now has an Export… button that writes it to a .lcprint file, and Import… beside Save adds templates from such files. print.template.export {name, path} writes {format, version, name, settings}. print.template.import {path} reads at most 256 KB, checks the format, version, name and every setting as a saved template's settings are checked, drops cell photos and guides, and adds the template under its name, or under "name (2)" and so on when the name is in use. The buttons appear where the host provides open and save dialogs: the desktop app does, and the web build does not. --- apps/lightcraft-web/src/web.rs | 2 + apps/lightcraft/src/main.rs | 18 +++++ crates/engine/src/cmd/print.rs | 60 ++++++++++++++- crates/engine/src/print/template.rs | 115 ++++++++++++++++++++++++++++ crates/engine/src/tests_print.rs | 72 +++++++++++++++++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/print_view.rs | 17 ++++ crates/ui-egui/src/tests_print.rs | 44 +++++++++++ docs/parity-checklist.md | 2 +- 9 files changed, 331 insertions(+), 3 deletions(-) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 14a5d050b..9bb34d614 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -224,6 +224,8 @@ fn services(originals: Originals, backend: Option, files: Files, frozen save_curve_preset_file: None, pick_slideshow_templates: None, save_slideshow_template: None, + pick_print_templates: None, + save_print_template: None, write: Some(Box::new(download)), // downloads happen on the main thread: exports run in the foreground on the web write_shared: None, diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 95739b3e9..f5cc971e5 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -704,6 +704,24 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> .save_file() .map(|p| p.to_string_lossy().to_string()) })), + pick_print_templates: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Print Templates")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_print_template: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Print Template")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), // atomic (temp file + sync + rename): a failed write never leaves a truncated file write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), write: Some(Box::new(lightcraft_engine::export::write_file)), diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 26f5d9bc4..541773273 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -8,12 +8,12 @@ //! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset` //! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear` //! * `print.exportPdf {path, ids?}` -//! * `print.template.list|save|apply|delete` +//! * `print.template.list|save|apply|delete`, `print.template.export|import` (`.lcprint` files) use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, f64_req}; use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size}; -use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name}; +use crate::print::template::{FILE_EXT, MAX_FILE, MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name, unique_name}; use crate::{Result, Session}; fn rect_json(r: &Rect) -> Value { @@ -318,6 +318,56 @@ fn template_delete(s: &mut Session, p: &Value) -> Result { Ok(template_list(s)) } +fn template_path<'a>(p: &'a Value, id: &str) -> Result<&'a str> { + p.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(id, "missing `path`")) +} + +fn template_export(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.export"; + let name = template_name(p, ID)?; + let mut path = std::path::PathBuf::from(template_path(p, ID)?); + if path.extension().is_none() { + path.set_extension(FILE_EXT); + } + let t = s + .print_templates + .iter() + .find(|x| x.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?; + let bytes = serde_json::to_vec_pretty(&t.to_file()).map_err(|e| crate::EngineError::Other(e.to_string()))?; + let name = t.name.clone(); + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad(ID, e))?; + crate::export::write_file(&target, &bytes).map_err(|e| bad(ID, e))?; + Ok(json!({"name": name, "path": target})) +} + +fn template_import(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.import"; + let path = std::path::Path::new(template_path(p, ID)?); + let meta = std::fs::metadata(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?; + if !meta.is_file() { + return Err(bad(ID, format!("{}: not a file", path.display()))); + } + if meta.len() > MAX_FILE { + return Err(bad(ID, format!("{}: {} bytes is too large for a print template (at most {} KB)", path.display(), meta.len(), MAX_FILE / 1024))); + } + let bytes = std::fs::read(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?; + let mut t = PrintTemplate::from_file(&bytes).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?; + if s.print_templates.len() >= MAX_TEMPLATES { + return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first"))); + } + let name = unique_name(&t.name, &s.print_templates); + let renamed = name != t.name; + t.name = name.clone(); + s.print_templates.push(t); + s.save_prefs()?; + let mut out = template_list(s); + out["name"] = json!(name); + out["renamed"] = json!(renamed); + Ok(out) +} + pub fn specs() -> Vec { vec![ cmd!(query "print.get", "Print Settings", [], None, @@ -349,5 +399,11 @@ pub fn specs() -> Vec { always, template_save), cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply), cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete), + cmd!(query "print.template.export", "Export Print Template", [], None, + "{name, path}: writes the template as a .lcprint JSON file ({format, version, name, settings}; `.lcprint` is added when the path has no extension); replaces an existing file, never an original → {name, path}", + always, template_export), + cmd!(query "print.template.import", "Import Print Template", [], None, + "{path: a .lcprint file (at most 256 KB)}: checked like a saved template (format, version, name, every setting; cell photos and guides are dropped) and added under its name; a name taken (any case) gets \" (2)\"… → print.template.list + {name, renamed}", + always, template_import), ] } diff --git a/crates/engine/src/print/template.rs b/crates/engine/src/print/template.rs index 2465c123a..7bb533612 100644 --- a/crates/engine/src/print/template.rs +++ b/crates/engine/src/print/template.rs @@ -30,6 +30,14 @@ impl PrintSettings { pub const MAX_NAME: usize = 80; /// Most templates kept. pub const MAX_TEMPLATES: usize = 200; +/// File extension of an exported print template (JSON). +pub const FILE_EXT: &str = "lcprint"; +/// `format` tag of an exported print template file. +const FILE_FORMAT: &str = "lightcraft.printTemplate"; +/// Version of the template file this build writes and the newest it reads. +const FILE_VERSION: u64 = 1; +/// Largest template file read, bytes. +pub const MAX_FILE: u64 = 256 * 1024; /// A saved print template: the layout, image settings, page and job of a print, without the /// photos assigned to cells and without the guides (a view preference). The settings are kept as @@ -74,6 +82,53 @@ impl PrintTemplate { s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?; Ok(s) } + + /// The document of an exported template: `{format, version, name, settings}`. + pub fn to_file(&self) -> serde_json::Value { + serde_json::json!({"format": FILE_FORMAT, "version": FILE_VERSION, "name": self.name, "settings": self.settings}) + } + + /// A template read from an exported file. Refused: more than [`MAX_FILE`] bytes, not JSON, + /// another format or a newer version, a bad name, and settings that fail the checks a saved + /// template's settings get. Photos assigned to cells and guides in the file are dropped. + pub fn from_file(bytes: &[u8]) -> Result { + if bytes.len() as u64 > MAX_FILE { + return Err(format!("{} bytes is too large for a print template (at most {} KB)", bytes.len(), MAX_FILE / 1024)); + } + let v: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| format!("not a print template file: {e}"))?; + if v.get("format").and_then(serde_json::Value::as_str) != Some(FILE_FORMAT) { + return Err(format!("not a print template file (`format` is not \"{FILE_FORMAT}\")")); + } + match v.get("version").and_then(serde_json::Value::as_u64) { + Some(1..=FILE_VERSION) => {} + Some(n) => return Err(format!("print template file version {n} is not one this version of LightCraft reads (1 to {FILE_VERSION})")), + None => return Err("a print template file needs a `version` number".into()), + } + let name = clean_name(v.get("name").and_then(serde_json::Value::as_str).ok_or("a print template file needs a `name`")?)?; + let settings = v.get("settings").cloned().ok_or("a print template file needs `settings`")?; + let checked = PrintTemplate { name: name.clone(), settings }.settings(&Guides::default())?; + Self::new(&name, &checked) + } +} + +/// `name` if no template in `taken` has it (in any case), else the first free `name (2)`, +/// `name (3)`…, with `name` shortened to keep the result within [`MAX_NAME`] characters. +pub fn unique_name(name: &str, taken: &[PrintTemplate]) -> String { + let free = |n: &str| !taken.iter().any(|t| t.name.eq_ignore_ascii_case(n)); + if free(name) { + return name.to_string(); + } + let mut n = 2usize; + loop { + let suffix = format!(" ({n})"); + let keep = MAX_NAME.saturating_sub(suffix.chars().count()); + let base: String = name.chars().take(keep).collect(); + let candidate = format!("{}{suffix}", base.trim_end()); + if free(&candidate) || n > taken.len() { + return candidate; + } + n += 1; + } } #[cfg(test)] @@ -121,4 +176,64 @@ mod tests { s.job.ppi = f32::NAN; assert!(PrintTemplate::new("t", &s).is_err()); } + + #[test] + fn a_template_file_reads_back_as_the_template() { + let mut s = PrintSettings::default(); + s.layout.style = Style::ContactSheet; + s.page.background = [1, 2, 3]; + let t = PrintTemplate::new("Proofs", &s).unwrap(); + let bytes = serde_json::to_vec(&t.to_file()).unwrap(); + assert_eq!(PrintTemplate::from_file(&bytes).unwrap(), t); + // a hand-written file: photos and guides are not taken in + let doc = json!({"format": FILE_FORMAT, "version": 1, "name": " Hand ", "settings": { + "layout": {"style": "customPackage", "cells": [{"id": 1, "rect": {"x": 1, "y": 1, "w": 2, "h": 2}, "photo": 9}]}, + "guides": {"gridSize": 1.0}}}); + let t = PrintTemplate::from_file(&serde_json::to_vec(&doc).unwrap()).unwrap(); + assert_eq!(t.name, "Hand"); + assert!(t.settings.get("guides").is_none()); + assert!(t.settings.pointer("/layout/cells/0/rect").is_some()); + assert_eq!(t.settings.pointer("/layout/cells/0/photo"), None); + } + + #[test] + fn hostile_template_files_are_refused() { + let file = |v: serde_json::Value| PrintTemplate::from_file(&serde_json::to_vec(&v).unwrap()).unwrap_err(); + let ok = |extra: serde_json::Value| { + let mut v = json!({"format": FILE_FORMAT, "version": 1, "name": "t", "settings": {}}); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + v + }; + assert!(PrintTemplate::from_file(b"not json").unwrap_err().contains("not a print template file")); + assert!(PrintTemplate::from_file(&vec![b' '; MAX_FILE as usize + 1]).unwrap_err().contains("too large")); + assert!(file(json!([1, 2])).contains("format")); + assert!(file(ok(json!({"format": "lightcraft.slideshowTemplate"}))).contains("format")); + assert!(file(ok(json!({"version": 2}))).contains("version 2")); + assert!(file(ok(json!({"version": 0}))).contains("version 0")); + assert!(file(ok(json!({"version": "1"}))).contains("`version` number")); + assert!(file(json!({"format": FILE_FORMAT, "name": "t", "settings": {}})).contains("`version` number")); + assert!(file(ok(json!({"name": "a\nb"}))).contains("template name")); + assert!(file(ok(json!({"name": 5}))).contains("`name`")); + assert!(file(json!({"format": FILE_FORMAT, "version": 1, "name": "t"})).contains("`settings`")); + assert!(file(ok(json!({"settings": {"job": {"ppi": 1e30}}}))).contains("template `t`")); + assert!(file(ok(json!({"settings": {"surprise": 1}}))).contains("template `t`")); + assert!(PrintTemplate::from_file(&serde_json::to_vec(&ok(json!({}))).unwrap()).is_ok()); + } + + #[test] + fn an_imported_name_never_takes_an_existing_one() { + let t = |n: &str| PrintTemplate::new(n, &PrintSettings::default()).unwrap(); + assert_eq!(unique_name("Proofs", &[]), "Proofs"); + assert_eq!(unique_name("Proofs", &[t("proofs")]), "Proofs (2)"); + assert_eq!(unique_name("Proofs", &[t("Proofs"), t("PROOFS (2)")]), "Proofs (3)"); + let long = "x".repeat(MAX_NAME); + let named = unique_name(&long, &[t(&long)]); + assert_eq!(named.chars().count(), MAX_NAME); + assert!(named.ends_with(" (2)")); + let many: Vec = std::iter::once(t("A")).chain((2..=MAX_TEMPLATES).map(|n| t(&format!("A ({n})")))).collect(); + let named = unique_name("A", &many); + assert!(!many.iter().any(|x| x.name.eq_ignore_ascii_case(&named)), "{named}"); + } } diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 898faa889..ca6ea486c 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -295,6 +295,76 @@ fn templates_save_apply_delete_and_persist() { assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); } +#[test] +fn templates_export_to_a_file_and_import_from_one() { + let files = MemStore::new(); + let open = |files: &MemStore| { + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + s + }; + let dir = std::env::temp_dir().join(format!("lightcraft-print-templates-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = open(&files); + run(&mut s, "print.set", json!({"layout": {"style": "contactSheet", "grid": {"columns": 3, "rows": 4}}, "page": {"background": [5, 6, 7]}})); + run(&mut s, "print.template.save", json!({"name": "Proofs"})); + + // the extension is added when the path has none + let r = run(&mut s, "print.template.export", json!({"name": "proofs", "path": dir.join("proofs").to_string_lossy()})); + assert_eq!(r["name"], "Proofs"); + let file = dir.join("proofs.lcprint"); + assert_eq!(r["path"], file.to_string_lossy().as_ref()); + let doc: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!((doc["format"].as_str(), doc["version"].as_u64(), doc["name"].as_str()), (Some("lightcraft.printTemplate"), Some(1), Some("Proofs"))); + + // into the same library: the name is taken, so the copy is renamed + let r = run(&mut s, "print.template.import", json!({"path": file.to_string_lossy()})); + assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs (2)"), Some(true)), "{r}"); + assert_eq!(r["templates"].as_array().unwrap().len(), 2); + assert_eq!(r["templates"][1]["settings"], r["templates"][0]["settings"]); + + // into another library, under its own name, and it is kept there + let other = MemStore::new(); + let mut b = open(&other); + let r = run(&mut b, "print.template.import", json!({"path": file.to_string_lossy()})); + assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs"), Some(false))); + let g = run(&mut open(&other), "print.template.apply", json!({"name": "Proofs"})); + assert_eq!(g["settings"]["layout"]["grid"]["columns"], 3); + assert_eq!(g["settings"]["page"]["background"], json!([5, 6, 7])); + + // refusals leave the templates as they were + let bad_file = dir.join("bad.lcprint"); + std::fs::write(&bad_file, br#"{"format": "lightcraft.printTemplate", "version": 1, "name": "Bad", "settings": {"layout": {"pages": 0}}}"#) + .unwrap(); + let huge = dir.join("huge.lcprint"); + std::fs::write(&huge, vec![b' '; crate::print::template::MAX_FILE as usize + 1]).unwrap(); + for (p, why) in [ + (json!({}), "missing `path`"), + (json!({"path": " "}), "missing `path`"), + (json!({"path": dir.join("absent.lcprint").to_string_lossy()}), "absent.lcprint"), + (json!({"path": dir.to_string_lossy()}), "not a file"), + (json!({"path": huge.to_string_lossy()}), "too large"), + (json!({"path": bad_file.to_string_lossy()}), "template `Bad`"), + ] { + let e = fails(&mut s, "print.template.import", p.clone()); + assert!(e.contains(why), "{p}: {e}"); + } + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 2); + assert!(fails(&mut s, "print.template.export", json!({"name": "Nope", "path": dir.join("x").to_string_lossy()})).contains("no print template")); + assert!(fails(&mut s, "print.template.export", json!({"name": "Proofs"})).contains("missing `path`")); + assert!(fails(&mut s, "print.template.export", json!({"path": dir.join("x").to_string_lossy()})).contains("missing `name`")); + assert!(!dir.join("x.lcprint").exists()); + + // a full list takes no more + while s.print_templates.len() < crate::print::template::MAX_TEMPLATES { + let n = s.print_templates.len(); + run(&mut s, "print.template.save", json!({"name": format!("T{n}")})); + } + assert!(fails(&mut s, "print.template.import", json!({"path": file.to_string_lossy()})).contains("at most")); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_hostile_template_in_the_preferences_does_not_break_the_library() { let files = MemStore::new(); @@ -336,6 +406,8 @@ fn every_print_command_is_registered_with_a_description() { "print.template.save", "print.template.apply", "print.template.delete", + "print.template.export", + "print.template.import", ] { let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing")); assert!(c.params.len() > 3, "{id}"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f23c9bd7..87a773be8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -273,6 +273,10 @@ pub struct Services { pub pick_slideshow_templates: Option, /// Save dialog for an exported slideshow template (`.json`). pub save_slideshow_template: Option, + /// Open dialog for print template files (`.lcprint`; File ▸ Print… ▸ Templates ▸ Import…). + pub pick_print_templates: Option, + /// Save dialog for an exported print template (`.lcprint`). + pub save_print_template: Option, pub write: Option, /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). pub write_shared: Option, diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index a18e1f691..2c34cffd6 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -197,6 +197,14 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, if text_button(ui, "printSaveTemplate", "Save", false).clicked() && !pu.template_name.trim().is_empty() { run(app, &ctx, "print.template.save", json!({"name": pu.template_name})); } + if app.services.pick_print_templates.is_some() && text_button(ui, "printTemplateImport", "Import…", false).clicked() { + let paths = app.services.pick_print_templates.as_mut().map(|pick| pick()).unwrap_or_default(); + for path in paths { + if let Some(r) = run(app, &ctx, "print.template.import", json!({"path": path})) { + app.toast(&ctx, crate::i18n::tr_format!("Imported {name}", name = r["name"].as_str().unwrap_or_default())); + } + } + } }); let names: Vec = app.session.print_templates.iter().map(|t| t.name.clone()).collect(); for (i, name) in names.iter().enumerate() { @@ -206,6 +214,15 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, if text_button(ui, &format!("printTemplateDelete-{i}"), "Delete", false).clicked() { run(app, &ctx, "print.template.delete", json!({"name": name})); } + if app.services.save_print_template.is_some() && text_button(ui, &format!("printTemplateExport-{i}"), "Export…", false).clicked() { + let stem = lightcraft_engine::slide_template::file_stem(name).unwrap_or_else(|| "Print Template".into()); + let file = format!("{stem}.{}", lightcraft_engine::print::template::FILE_EXT); + if let Some(path) = app.services.save_print_template.as_mut().and_then(|save| save(&file)) + && let Some(r) = run(app, &ctx, "print.template.export", json!({"name": name, "path": path})) + { + app.toast(&ctx, crate::i18n::tr_format!("Exported to {path}", path = r["path"].as_str().unwrap_or_default())); + } + } if text_button(ui, &format!("printTemplateApply-{i}"), "Apply", false).clicked() { run(app, &ctx, "print.template.apply", json!({"name": name})); pu.page = 0; diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs index 28838f409..25f7014ee 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests_print.rs @@ -214,6 +214,50 @@ fn templates_save_apply_and_delete_from_the_dialog() { assert!(h.app.session.print_templates.is_empty()); } +#[test] +fn templates_export_and_import_from_the_dialog() { + let dir = std::env::temp_dir().join(format!("lightcraft-ui-print-templates-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Sheet 5x6.lcprint").to_string_lossy().to_string(); + let suggested = Arc::new(Mutex::new(Vec::::new())); + let names = suggested.clone(); + let (save_to, pick) = (file.clone(), file.clone()); + let services = Services { + save_print_template: Some(Box::new(move |name: &str| { + names.lock().unwrap().push(name.to_string()); + Some(save_to.clone()) + })), + pick_print_templates: Some(Box::new(move || vec![pick.clone()])), + ..Default::default() + }; + let mut h = demo(services); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + cmd(&mut h, "print.set", json!({"layout": {"grid": {"columns": 5, "rows": 6}}})); + h.app.ui.print.template_name = "Sheet 5x6".into(); + click(&mut h, "button:printSaveTemplate"); + click(&mut h, "button:printTemplateExport-0"); + assert_eq!(*suggested.lock().unwrap(), vec!["Sheet 5x6.lcprint".to_string()]); + assert!(std::path::Path::new(&file).is_file()); + click(&mut h, "button:printTemplateImport"); + let names: Vec<&str> = h.app.session.print_templates.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, ["Sheet 5x6", "Sheet 5x6 (2)"]); + assert_eq!(h.app.session.print_templates[1].settings, h.app.session.print_templates[0].settings); + assert!(has(&h, "button:printTemplateExport-1"), "the imported template is listed"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn template_files_need_the_hosts_file_dialogs() { + let mut h = demo(Services::default()); + open_print(&mut h); + h.app.ui.print.template_name = "Plain".into(); + click(&mut h, "button:printSaveTemplate"); + assert!(has(&h, "button:printTemplateApply-0")); + assert!(!has(&h, "button:printTemplateExport-0"), "no save dialog: no Export…"); + assert!(!has(&h, "button:printTemplateImport"), "no open dialog: no Import…"); +} + #[test] fn print_to_pdf_picks_a_file_and_runs_in_the_background() { let asked = Arc::new(Mutex::new(Vec::::new())); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9790523e1..fc225468e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -765,7 +765,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | | LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; Page Options for Single Image and Contact Sheet (`print.set {page: {pageNumbers, pageInfo, cropMarks}}`, checkboxes in the Page section, kept in templates): the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, crop marks at each photo's corners, drawn in the preview and in the PDF (`print::compose::tests::crop_marks_sit_outside_each_photo_corner`); filename captions in the contact-sheet PDF; no identity plate, watermark, photo info or package Cut Guides | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | -| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | +| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `cmd:print.template.export`, `cmd:print.template.import`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; Export… writes a template to a `.lcprint` file and Import… adds one from such a file (checked like a saved template; a name already in use gets " (2)"); no built-in templates, no folders | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | | LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | |