LRC-PRINT-TEMPLATES: print templates export to a file and import from one

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.
This commit is contained in:
dotCooCoo
2026-10-23 23:18:35 -07:00
parent 4ae3089420
commit 59e9a6a4f3
9 changed files with 331 additions and 3 deletions
+2
View File
@@ -224,6 +224,8 @@ fn services(originals: Originals, backend: Option<Backend>, 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,
+18
View File
@@ -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)),
+58 -2
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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<CommandSpec> {
vec![
cmd!(query "print.get", "Print Settings", [], None,
@@ -349,5 +399,11 @@ pub fn specs() -> Vec<CommandSpec> {
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),
]
}
+115
View File
@@ -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<Self, String> {
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<PrintTemplate> = 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}");
}
}
+72
View File
@@ -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}");
+4
View File
@@ -273,6 +273,10 @@ pub struct Services {
pub pick_slideshow_templates: Option<PickFiles>,
/// Save dialog for an exported slideshow template (`.json`).
pub save_slideshow_template: Option<SaveFile>,
/// Open dialog for print template files (`.lcprint`; File ▸ Print… ▸ Templates ▸ Import…).
pub pick_print_templates: Option<PickFiles>,
/// Save dialog for an exported print template (`.lcprint`).
pub save_print_template: Option<SaveFile>,
pub write: Option<WriteFn>,
/// Thread-safe writer: with it, UI-started exports run in the background (desktop only).
pub write_shared: Option<SharedWrite>,
+17
View File
@@ -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<String> = 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;
+44
View File
@@ -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::<String>::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::<crate::pick::PickRequest>::new()));
+1 -1
View File
@@ -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 | 🚫 | | |