mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Merge upstream/main into canon-colordata-kelvin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ const SAME: f32 = 0.93;
|
||||
|
||||
fn secs(iso: &str) -> Option<i64> {
|
||||
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
|
||||
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
|
||||
Some(d.unix_seconds())
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -1488,7 +1488,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
|
||||
@@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Metadata-only fixtures: no import, image decoding or model/service dependency.
|
||||
for id in 1..=4 {
|
||||
std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap();
|
||||
s.catalog
|
||||
.apply(Op::SetFile {
|
||||
id: PhotoId(id),
|
||||
|
||||
@@ -9,8 +9,13 @@ use crate::Result;
|
||||
|
||||
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
|
||||
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
|
||||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
Ok((s.targets(p)?, template, start))
|
||||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1);
|
||||
let ids = s.targets(p)?;
|
||||
// every number of the sequence `start..start + n` must fit
|
||||
let last = start.checked_add((ids.len() as u64).saturating_sub(1));
|
||||
let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok());
|
||||
let start = start.ok_or_else(|| bad(c, "start out of range"))?;
|
||||
Ok((ids, template, start))
|
||||
}
|
||||
|
||||
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
|
||||
|
||||
@@ -324,7 +324,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
|
||||
always, get),
|
||||
cmd!(query "print.set", "Set Print Settings", [], None,
|
||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||
always, set),
|
||||
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
|
||||
cmd!(query "print.cell.add", "Add Print Cell", [], None,
|
||||
|
||||
@@ -99,6 +99,21 @@ fn adjust(c: &XmpValue) -> Value {
|
||||
Value::Object(a)
|
||||
}
|
||||
|
||||
/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set:
|
||||
/// reported, never dropped silently.
|
||||
fn unmapped_local(c: &XmpValue) -> Vec<String> {
|
||||
let XmpValue::Struct(m) = c else { return Vec::new() };
|
||||
m.keys()
|
||||
.filter_map(|k| {
|
||||
let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?;
|
||||
// 100 = "curve saturation unchanged", written with every correction
|
||||
let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 };
|
||||
let set = num(c, k).is_some_and(|v| v != neutral);
|
||||
(set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
|
||||
/// luminance on −8..+4 EV mapped to 0..1.
|
||||
fn lightness_to_range(l: f64) -> f64 {
|
||||
@@ -214,8 +229,12 @@ fn component(m: &XmpValue, container: &str, aspect: f64) -> Result<Value, String
|
||||
Some("2") => {
|
||||
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
|
||||
// full strength at white (1, the top of their scale) takes in everything brighter: ours
|
||||
// goes on above scene white, so that end stays open instead of cutting off at 0 EV
|
||||
let open_top = c >= 1.0;
|
||||
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
|
||||
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
|
||||
let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) };
|
||||
json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather})
|
||||
}
|
||||
Some("3") => {
|
||||
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
@@ -252,6 +271,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
skipped.extend(unmapped_local(c));
|
||||
// brush dabs of one correction are one brush component
|
||||
let mut merged: Vec<Value> = Vec::new();
|
||||
for p in parts {
|
||||
@@ -506,6 +526,28 @@ mod tests {
|
||||
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
|
||||
// written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
|
||||
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
|
||||
<crs:MaskGroupBasedCorrections><rdf:Seq>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.3" crs:LocalGrain="0.4" crs:LocalCurveRefineSaturation="100">
|
||||
<crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/RangeMask"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.6 0.85 1 1"/></rdf:li>
|
||||
</rdf:Seq></crs:CorrectionMasks>
|
||||
</rdf:Description></rdf:li>
|
||||
</rdf:Seq></crs:MaskGroupBasedCorrections>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let d = lightcraft_meta::parse_xmp(x).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
|
||||
let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
|
||||
assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
|
||||
assert_eq!(s.masks[0].adjust.clarity, -30.0);
|
||||
assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_corrections() {
|
||||
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the
|
||||
//! originals a Lightroom catalog lists.
|
||||
//!
|
||||
//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the
|
||||
//! document's folder, and only local files, or files on the network share the document itself is
|
||||
//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing
|
||||
//! file.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use craft_docpath::Refused;
|
||||
|
||||
/// Why a stored path is not followed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotFollowed {
|
||||
/// The path is on another computer (other than the document's share), names a device, is a
|
||||
/// URL, or is not a usable path.
|
||||
Refused(Refused),
|
||||
/// The path is not an absolute path on this computer, such as a Windows drive path read on
|
||||
/// macOS or Linux.
|
||||
NotHere,
|
||||
}
|
||||
|
||||
impl fmt::Display for NotFollowed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Refused(r) => r.fmt(f),
|
||||
Self::NotHere => f.write_str("the path is not a path on this computer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NotFollowed {}
|
||||
|
||||
/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`,
|
||||
/// or why it is not followed. A relative `document` is taken from the working directory, where it
|
||||
/// was opened from.
|
||||
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> {
|
||||
let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() });
|
||||
let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// document does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NotHere);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() {
|
||||
let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat");
|
||||
assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg")));
|
||||
assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere));
|
||||
assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device)));
|
||||
}
|
||||
}
|
||||
@@ -1310,7 +1310,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
// Opaque photo exports need no alpha channel (#1670). Keep real transparency for
|
||||
// callers of this encoder, and leave unrelated formats on their existing path.
|
||||
let rgb: Option<Vec<u8>> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp)
|
||||
&& img.data.iter().all(|p| p.last() == Some(&255)))
|
||||
.then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect());
|
||||
let e = match &rgb {
|
||||
Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)),
|
||||
None => EncodeImage::rgba8(&img),
|
||||
};
|
||||
let r = match o.format {
|
||||
ExportFormat::Jpeg => {
|
||||
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
|
||||
@@ -2281,6 +2289,43 @@ mod tests {
|
||||
img
|
||||
}
|
||||
|
||||
/// Issue #1670: removing a redundant alpha channel preserves samples and metadata.
|
||||
#[test]
|
||||
fn opaque_eight_bit_exports_are_rgb() {
|
||||
let img = test_image();
|
||||
let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() };
|
||||
for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
|
||||
for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||||
let options = ExportOptions { format, tiff_compression: compression, ..Default::default() };
|
||||
let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap();
|
||||
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
|
||||
assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB");
|
||||
assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8));
|
||||
assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels");
|
||||
assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice()));
|
||||
assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test"));
|
||||
if format == ExportFormat::Png {
|
||||
assert_eq!(bytes[25], 2, "PNG IHDR declares RGB");
|
||||
}
|
||||
if format != ExportFormat::Tiff {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_bit_exports_preserve_real_alpha() {
|
||||
let mut img = test_image();
|
||||
img.data[0][3] = 128;
|
||||
for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
|
||||
let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap();
|
||||
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
|
||||
assert!(decoded.has_alpha, "{format:?} must retain real alpha");
|
||||
assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_have_magic() {
|
||||
let img = test_image();
|
||||
|
||||
@@ -167,6 +167,9 @@ impl Index {
|
||||
self.entries.insert((photo, key), (emb.into(), view));
|
||||
}
|
||||
}
|
||||
// a half-written last record (a crash while appending): written fresh at the next save, so later records are
|
||||
// not appended after it and shifted
|
||||
self.rewrite = !rest.is_empty();
|
||||
}
|
||||
|
||||
fn header(&self) -> Vec<u8> {
|
||||
@@ -201,12 +204,11 @@ impl Index {
|
||||
return Ok(());
|
||||
}
|
||||
if self.file_matches && !self.rewrite {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let mut buf = Vec::new();
|
||||
for (k, e) in &self.unsaved {
|
||||
buf.extend(self.record(k, e));
|
||||
}
|
||||
f.write_all(&buf)?;
|
||||
append(&path, &buf)?;
|
||||
} else {
|
||||
let mut buf = self.header();
|
||||
for (k, e) in &self.entries {
|
||||
@@ -229,6 +231,22 @@ impl Drop for Index {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so
|
||||
/// the part that got in does not shift what is appended later.
|
||||
fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
|
||||
let old_len = f.metadata()?.len();
|
||||
let written = f.write_all(buf);
|
||||
if written.is_err() {
|
||||
drop(f);
|
||||
// an append-only handle may not truncate (Windows): cut through a writable one
|
||||
if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) {
|
||||
let _ = w.set_len(old_len);
|
||||
}
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
const SCAN_MAGIC: &[u8; 4] = b"LCFS";
|
||||
const SCAN_VERSION: u8 = 1;
|
||||
/// The detector a search was made with: a record made by another one is ignored (a better detector may find more).
|
||||
@@ -325,8 +343,11 @@ impl Scanned {
|
||||
let head = self.header();
|
||||
let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return };
|
||||
self.file_matches = true;
|
||||
// a half-written last id (a crash while appending) is ignored
|
||||
self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c)));
|
||||
// a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save
|
||||
// so later ids are not appended after it and shifted
|
||||
let (ids, torn) = rest.as_chunks::<8>();
|
||||
self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c)));
|
||||
self.rewrite = !torn.is_empty();
|
||||
}
|
||||
|
||||
/// Write what was added since the last save: appended when the file is this detector's, else the whole record
|
||||
@@ -341,9 +362,8 @@ impl Scanned {
|
||||
return Ok(());
|
||||
}
|
||||
if self.file_matches && !self.rewrite {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
|
||||
f.write_all(&buf)?;
|
||||
append(&path, &buf)?;
|
||||
} else {
|
||||
let mut buf = self.header();
|
||||
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
|
||||
@@ -487,6 +507,51 @@ mod tests {
|
||||
assert!(!t.contains(1) && t.contains(2) && t.contains(3));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A torn last record (a crash or a full disk while appending) does not shift the records appended after it
|
||||
/// (issue #2129).
|
||||
#[test]
|
||||
fn records_appended_after_a_torn_tail_stay_aligned() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin"));
|
||||
let junk = |p: &Path| {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap();
|
||||
f.write_all(&[0x3f; 5]).unwrap();
|
||||
};
|
||||
let mut a = Index::default();
|
||||
a.reset("m@abc", 3, Some(emb.clone()));
|
||||
a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]);
|
||||
a.save().unwrap();
|
||||
drop(a);
|
||||
junk(&emb);
|
||||
let mut b = Index::default();
|
||||
b.reset("m@abc", 3, Some(emb.clone()));
|
||||
b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]);
|
||||
b.save().unwrap();
|
||||
drop(b);
|
||||
let mut c = Index::default();
|
||||
c.reset("m@abc", 3, Some(emb));
|
||||
assert_eq!(c.len(), 2);
|
||||
assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5]));
|
||||
assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125]));
|
||||
let mut s = Scanned::default();
|
||||
s.reset(Some(scan.clone()));
|
||||
s.insert(42);
|
||||
s.save().unwrap();
|
||||
drop(s);
|
||||
junk(&scan);
|
||||
let mut t = Scanned::default();
|
||||
t.reset(Some(scan.clone()));
|
||||
t.insert(43);
|
||||
t.save().unwrap();
|
||||
drop(t);
|
||||
let mut u = Scanned::default();
|
||||
u.reset(Some(scan));
|
||||
assert!(u.contains(42) && u.contains(43) && u.len() == 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the recognition commands keep between calls: the loaded model and the embeddings made with it.
|
||||
|
||||
@@ -707,8 +707,9 @@ pub(crate) fn decode_raw_preview(
|
||||
covering: Option<usize>,
|
||||
) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = stand_in_image(bytes, covering)?;
|
||||
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
|
||||
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
|
||||
// the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview
|
||||
// wrapped as a TIFF) and a JPEG XL preview are read by their own decoders
|
||||
let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]);
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
|
||||
Some(space) => {
|
||||
let space = match space {
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod demo;
|
||||
pub mod denoise;
|
||||
pub mod devices;
|
||||
pub mod display;
|
||||
pub mod doc_path;
|
||||
mod errors;
|
||||
pub mod export;
|
||||
pub mod face_download;
|
||||
@@ -56,6 +57,7 @@ pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
mod model_download;
|
||||
pub mod open_path;
|
||||
pub mod originals;
|
||||
mod pdf;
|
||||
pub mod preset_import;
|
||||
@@ -298,6 +300,10 @@ pub struct Session {
|
||||
pub xmp: sidecar::XmpPrefs,
|
||||
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
|
||||
pub last_export: Option<serde_json::Value>,
|
||||
/// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`,
|
||||
/// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses
|
||||
/// them. Persisted in prefs.json.
|
||||
pub last_merge: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
|
||||
pub export_presets: Vec<export::ExportPreset>,
|
||||
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
|
||||
@@ -459,6 +465,7 @@ impl Session {
|
||||
library_identity: Arc::new(()),
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
last_merge: Default::default(),
|
||||
export_presets: Vec::new(),
|
||||
print: Default::default(),
|
||||
print_templates: Vec::new(),
|
||||
|
||||
@@ -207,6 +207,8 @@ struct PrefsFile {
|
||||
/// Parameters of the last export (for Export with Previous).
|
||||
#[serde(alias = "last_export")]
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// Options of the last Photo Merge, per command.
|
||||
last_merge: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
/// The user's export presets.
|
||||
export_presets: Vec<crate::export::ExportPreset>,
|
||||
/// Print templates.
|
||||
@@ -492,6 +494,7 @@ impl Session {
|
||||
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.last_merge = prefs.last_merge;
|
||||
self.export_presets = prefs.export_presets;
|
||||
self.print_templates = prefs.print_templates;
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
@@ -746,6 +749,7 @@ impl Session {
|
||||
let v = serde_json::to_vec_pretty(&PrefsFile {
|
||||
xmp: self.xmp,
|
||||
last_export: self.last_export.clone(),
|
||||
last_merge: self.last_merge.clone(),
|
||||
export_presets: self.export_presets.clone(),
|
||||
print_templates: self.print_templates.clone(),
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
|
||||
@@ -538,6 +538,7 @@ mod tests {
|
||||
keywords: vec!["Trips|Goa".into()],
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
|
||||
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
|
||||
original: crate::lightroom_catalog::Original::Local,
|
||||
};
|
||||
CatalogImport {
|
||||
source: "source.lrcat".into(),
|
||||
@@ -621,6 +622,7 @@ mod tests {
|
||||
keywords: Vec::new(),
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
|
||||
snapshots: Vec::new(),
|
||||
original: crate::lightroom_catalog::Original::Local,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
|
||||
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
@@ -146,6 +146,31 @@ pub struct CatalogPhoto {
|
||||
pub keywords: Vec<String>,
|
||||
pub history: Vec<Row>,
|
||||
pub snapshots: Vec<Row>,
|
||||
/// Whether `path` may be looked at (derived when the catalog is read; not archived).
|
||||
#[serde(skip)]
|
||||
pub original: Original,
|
||||
}
|
||||
|
||||
/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog
|
||||
/// as the document ([`crate::doc_path::resolve`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Original {
|
||||
/// `path` is a local file path, or one on the network share the catalog is on.
|
||||
Local,
|
||||
/// `path` is not a path on this computer (a catalog made on another system): the photo is
|
||||
/// kept as an offline record for later relinking, and the path is not looked at.
|
||||
#[default]
|
||||
NotHere,
|
||||
/// `path` is refused (why): it is on another computer or a device, or its folder or file name
|
||||
/// is an absolute path. The photo is reported missing and not added to the library.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
impl CatalogPhoto {
|
||||
/// Whether the original is a file here; only a [`Original::Local`] path is looked at.
|
||||
pub fn original_is_file(&self) -> bool {
|
||||
self.original == Original::Local && Path::new(&self.path).is_file()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -343,25 +368,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> {
|
||||
|
||||
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
|
||||
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
|
||||
/// catalog's volume, resolved against the catalog's folder, if that is a folder.
|
||||
/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are
|
||||
/// looked at only when [`crate::doc_path::resolve`] follows them.
|
||||
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> {
|
||||
if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
|
||||
if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) {
|
||||
return None;
|
||||
}
|
||||
let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
|
||||
let mut out = PathBuf::new();
|
||||
for part in dir.components() {
|
||||
match part {
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Component::CurDir => {}
|
||||
part => out.push(part),
|
||||
}
|
||||
crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir())
|
||||
}
|
||||
|
||||
/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in
|
||||
/// place of the root folder.
|
||||
fn is_absolute_part(part: &str) -> bool {
|
||||
let bytes = part.as_bytes();
|
||||
part.starts_with(['/', '\\'])
|
||||
|| (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':'))
|
||||
|| Path::new(part).is_absolute()
|
||||
}
|
||||
|
||||
/// The path of an original under `root` and whether it may be looked at, resolved with the
|
||||
/// catalog at `catalog` as the document.
|
||||
fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) {
|
||||
let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string();
|
||||
if is_absolute_part(folder) || is_absolute_part(name) {
|
||||
return (stored, Original::Refused("its folder or file name is an absolute path".into()));
|
||||
}
|
||||
match crate::doc_path::resolve(&stored, Some(catalog)) {
|
||||
Ok(found) => (found.to_string_lossy().to_string(), Original::Local),
|
||||
Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere),
|
||||
Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())),
|
||||
}
|
||||
out.is_dir().then_some(out)
|
||||
}
|
||||
|
||||
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
|
||||
@@ -457,6 +493,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
out
|
||||
};
|
||||
let mut root_paths = HashMap::new();
|
||||
let mut refused_roots = HashSet::new();
|
||||
let mut root_ids: Vec<_> = roots.keys().copied().collect();
|
||||
root_ids.sort_unstable();
|
||||
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
|
||||
@@ -469,6 +506,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
}
|
||||
None => absolute.to_string(),
|
||||
};
|
||||
if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) {
|
||||
warnings
|
||||
.push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name")));
|
||||
refused_roots.insert(id);
|
||||
}
|
||||
root_paths.insert(id, resolved);
|
||||
}
|
||||
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
|
||||
@@ -487,13 +529,19 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
|
||||
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
|
||||
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
|
||||
let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str);
|
||||
let root_id = number(root, "id_local");
|
||||
let root_path = root_paths.get(&root_id).map_or("", String::as_str);
|
||||
let folder_path = text(folder, "pathFromRoot");
|
||||
let name = text(file, "idx_filename");
|
||||
if root_path.is_empty() || name.is_empty() {
|
||||
return Err(format!("image {id}: incomplete original path"));
|
||||
}
|
||||
let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string();
|
||||
let (photo_path, original) = original_path(root_path, folder_path, name, path);
|
||||
if let Original::Refused(why) = &original
|
||||
&& !refused_roots.contains(&root_id)
|
||||
{
|
||||
out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added"));
|
||||
}
|
||||
let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -504,19 +552,64 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
out.photos.push(CatalogPhoto {
|
||||
source_id: id,
|
||||
uuid: text(&image, "id_global").into(),
|
||||
path,
|
||||
path: photo_path,
|
||||
image,
|
||||
settings: develops.get(&id).cloned().unwrap_or_default(),
|
||||
xmp: packet,
|
||||
keywords: keywords.remove(&id).unwrap_or_default(),
|
||||
history: history.remove(&id).unwrap_or_default(),
|
||||
snapshots: snapshots.remove(&id).unwrap_or_default(),
|
||||
original,
|
||||
});
|
||||
}
|
||||
refuse_non_media(&mut out);
|
||||
ensure_read_active(cancel)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
|
||||
/// import's warnings.
|
||||
///
|
||||
/// The catalog decides these paths, and a record it leaves behind is one click from the system
|
||||
/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`,
|
||||
/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run
|
||||
/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not
|
||||
/// openable. A virtual copy of a refused original goes with it — it has no original of its own.
|
||||
///
|
||||
/// Called where the catalog is read and again where its photos become records, so neither route
|
||||
/// can keep one; the second pass finds nothing left to say.
|
||||
pub(crate) fn refuse_non_media(data: &mut CatalogImport) {
|
||||
// masters first: a virtual copy may come before its master in the table
|
||||
let refused: HashSet<i64> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path)))
|
||||
.map(|p| p.source_id)
|
||||
.collect();
|
||||
if refused.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
data.photos.retain(|p| {
|
||||
let master = number(&p.image, "masterImage");
|
||||
// `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not
|
||||
// take every master of the catalog with it
|
||||
if master != 0 {
|
||||
if !refused.contains(&master) {
|
||||
return true;
|
||||
}
|
||||
warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
|
||||
return false;
|
||||
}
|
||||
if !refused.contains(&p.source_id) {
|
||||
return true;
|
||||
}
|
||||
warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path));
|
||||
false
|
||||
});
|
||||
data.warnings.extend(warnings);
|
||||
}
|
||||
|
||||
fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
|
||||
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
|
||||
}
|
||||
@@ -665,11 +758,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) ->
|
||||
let paths: Vec<_> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file())
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file())
|
||||
.map(|p| p.path.clone())
|
||||
.collect();
|
||||
let missing_sources: HashSet<String> =
|
||||
data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect();
|
||||
data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect();
|
||||
let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?;
|
||||
let applied =
|
||||
apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?;
|
||||
@@ -692,8 +785,10 @@ pub(crate) struct ApplyPrepared<'a> {
|
||||
pub(crate) undo0: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
|
||||
pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
|
||||
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
|
||||
// nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
|
||||
refuse_non_media(&mut data);
|
||||
validate(&data).map_err(error)?;
|
||||
let mut warnings = data.warnings.clone();
|
||||
let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();
|
||||
@@ -713,12 +808,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
let mut photos: Vec<_> = data.photos.iter().collect();
|
||||
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
|
||||
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
|
||||
let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect();
|
||||
let mut planned: HashMap<PhotoId, Photo> = HashMap::new();
|
||||
for src in photos {
|
||||
let master = number(&src.image, "masterImage");
|
||||
if master == 0 && missing_sources.contains(&key(&src.path)) {
|
||||
missing.push(src.path.clone());
|
||||
}
|
||||
// A refused path must not become a library record the app would look at later; its
|
||||
// virtual copies go with it.
|
||||
if refused.contains(&src.source_id) || refused.contains(&master) {
|
||||
continue;
|
||||
}
|
||||
let indexed = index
|
||||
.photos
|
||||
.get(&identity(&data, src))
|
||||
@@ -940,6 +1041,7 @@ mod tests {
|
||||
keywords: vec!["Travel|Goa".into()],
|
||||
history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))],
|
||||
snapshots: Vec::new(),
|
||||
original: Original::Local,
|
||||
};
|
||||
let mut copy = photo.clone();
|
||||
copy.source_id = 2;
|
||||
@@ -962,6 +1064,48 @@ mod tests {
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
|
||||
/// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual
|
||||
/// copies go with it; the catalog's real photos still import.
|
||||
#[test]
|
||||
fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() {
|
||||
let mut data = sample();
|
||||
let mut exe = data.photos[1].clone();
|
||||
exe.source_id = 3;
|
||||
exe.uuid = "payload".into();
|
||||
exe.path = "/Users/sky/Downloads/payload.exe".into();
|
||||
exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10}));
|
||||
let mut exe_copy = exe.clone();
|
||||
exe_copy.source_id = 4;
|
||||
exe_copy.uuid = "payload-copy".into();
|
||||
exe_copy.path = "/Users/sky/Downloads/payload.exe".into();
|
||||
exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"}));
|
||||
// the copy comes before its master, as a catalog's table order may have it
|
||||
data.photos.insert(0, exe_copy);
|
||||
data.photos.push(exe);
|
||||
for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] {
|
||||
let mut one = data.clone();
|
||||
one.photos.retain(|p| p.source_id < 3);
|
||||
let mut planted = one.photos[1].clone();
|
||||
planted.source_id = 5;
|
||||
planted.uuid = "planted".into();
|
||||
planted.path = bad.into();
|
||||
planted.image = row(json!({"id_local":5,"fileFormat":"ARW"}));
|
||||
one.photos.push(planted);
|
||||
let mut s = crate::Session::new();
|
||||
let r = apply(&mut s, one, false).unwrap();
|
||||
assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}");
|
||||
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record");
|
||||
}
|
||||
let mut s = crate::Session::new();
|
||||
let r = apply(&mut s, data, false).unwrap();
|
||||
assert_eq!(r["photos"], 2, "only the real master and its copy: {r}");
|
||||
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued");
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}");
|
||||
assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
|
||||
let mut s = crate::Session::new();
|
||||
@@ -1508,11 +1652,57 @@ mod tests {
|
||||
// a relative path that leads nowhere keeps the recorded one
|
||||
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
|
||||
assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
|
||||
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn originals_on_other_computers_are_never_looked_at_or_added() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Photos")).unwrap();
|
||||
std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
|
||||
let source = dir.join("source.lrcat");
|
||||
let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()];
|
||||
if cfg!(unix) {
|
||||
// `//<local folder>/` is a local folder to Linux, but a network path to the resolver.
|
||||
roots.push(format!("/{}/", dir.display()));
|
||||
}
|
||||
for root in &roots {
|
||||
std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]);
|
||||
assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings);
|
||||
let mut s = crate::Session::new();
|
||||
let probes = Arc::new(AtomicUsize::new(0));
|
||||
let calls = probes.clone();
|
||||
s.media.file_probe = Some(Arc::new(move |_| {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Err("a refused original must not be probed".into())
|
||||
}));
|
||||
let report = import(&mut s, &source, false).unwrap();
|
||||
assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}");
|
||||
assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}");
|
||||
assert!(s.catalog.is_empty(), "{root}");
|
||||
}
|
||||
// An absolute folder path would replace the root.
|
||||
let mut tables = photo_tables((dir.to_str().unwrap(), ""));
|
||||
tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display()));
|
||||
std::fs::write(&source, native_catalog(&tables)).unwrap();
|
||||
assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_)));
|
||||
// A root beside the catalog is followed.
|
||||
std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert_eq!(catalog.photos[0].original, Original::Local);
|
||||
assert!(catalog.photos[0].original_is_file());
|
||||
// A root that moved is found through its path relative to the catalog.
|
||||
std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_optional_table_warns_and_is_skipped() {
|
||||
let mut main = vec![0u8; 2 * 512];
|
||||
|
||||
@@ -329,7 +329,7 @@ fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(V
|
||||
if !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
if Path::new(&photo.path).is_file() {
|
||||
if photo.original_is_file() {
|
||||
paths.push(photo.path.clone());
|
||||
} else {
|
||||
missing.insert(key);
|
||||
@@ -393,6 +393,7 @@ mod tests {
|
||||
keywords: Vec::new(),
|
||||
history: Vec::new(),
|
||||
snapshots: Vec::new(),
|
||||
original: lightroom_catalog::Original::Local,
|
||||
}],
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
@@ -531,6 +532,7 @@ mod tests {
|
||||
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
|
||||
)])],
|
||||
snapshots: Vec::new(),
|
||||
original: lightroom_catalog::Original::Local,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Whether a path the library holds may be handed to the operating system.
|
||||
//!
|
||||
//! A photo's `Source::File` path is not always something a user typed: it can come from an
|
||||
//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder.
|
||||
//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever
|
||||
//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta`
|
||||
//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135).
|
||||
//!
|
||||
//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular
|
||||
//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which
|
||||
//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must
|
||||
//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the
|
||||
//! absolute path to hand over; what comes back on refusal is the sentence to show the user.
|
||||
//!
|
||||
//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may
|
||||
//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a
|
||||
//! refused original never becomes a library record in the first place.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The absolute path to hand to an opener or external editor, or the reason it is refused.
|
||||
///
|
||||
/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses
|
||||
/// the path's own file name.
|
||||
pub fn openable(path: &str, label: &str) -> Result<String, String> {
|
||||
let p = Path::new(path);
|
||||
let name = match label.trim() {
|
||||
"" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()),
|
||||
l => l.to_string(),
|
||||
};
|
||||
if !is_media(p) {
|
||||
return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application"));
|
||||
}
|
||||
match std::fs::metadata(p) {
|
||||
Ok(m) if m.is_file() => {}
|
||||
Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")),
|
||||
Err(e) => return Err(format!("the original is missing: {path} ({e})")),
|
||||
}
|
||||
// a link may name a photo and point at a program; what runs is what it points at
|
||||
if let Ok(target) = p.canonicalize()
|
||||
&& !is_media(&target)
|
||||
{
|
||||
return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display()));
|
||||
}
|
||||
let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?;
|
||||
Ok(absolute.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included).
|
||||
pub fn is_media(path: &Path) -> bool {
|
||||
crate::import::is_supported(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, name: &str) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, b"x").unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_file_types_lightcraft_reads_are_handed_over() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] {
|
||||
let path = write(&dir, good);
|
||||
assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads");
|
||||
}
|
||||
for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] {
|
||||
let path = write(&dir, bad);
|
||||
let e = openable(&path, "").unwrap_err();
|
||||
assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}");
|
||||
}
|
||||
// a bundle (macOS `.app`) and any other folder are not files
|
||||
let bundle = dir.join("Evil.app");
|
||||
std::fs::create_dir_all(&bundle).unwrap();
|
||||
let e = openable(&bundle.to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}");
|
||||
let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("missing"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A relative path is made absolute: the opener runs with its own working directory.
|
||||
#[test]
|
||||
fn the_path_handed_over_is_absolute() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = write(&dir, "a.jpg");
|
||||
let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg");
|
||||
let out = openable(&dotted.to_string_lossy(), "").unwrap();
|
||||
assert!(Path::new(&out).is_absolute(), "{out}");
|
||||
assert!(out.ends_with("a.jpg"), "{out}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A link named like a photo that points at a program is refused: what the opener runs is
|
||||
/// the target, not the name.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_to_a_program_is_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let target = write(&dir, "payload.command");
|
||||
let link = dir.join("holiday.jpg");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
let e = openable(&link.to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("points at") && e.contains("payload.command"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
|
||||
//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
|
||||
//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop
|
||||
//! marks, page number, page info) and returns [`PageRaster`]s.
|
||||
//!
|
||||
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
|
||||
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
|
||||
@@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0;
|
||||
pub const MAX_STROKE_PT: f64 = 36.0;
|
||||
/// Renders kept for cells that print the same photo at the same size.
|
||||
const RENDER_CACHE: usize = 4;
|
||||
/// A crop mark's length and its gap from the photo's corner, inches.
|
||||
pub const CROP_MARK_IN: f64 = 0.25;
|
||||
pub const CROP_MARK_GAP_IN: f64 = 0.0625;
|
||||
/// A crop mark's weight, points.
|
||||
pub const CROP_MARK_PT: f64 = 0.5;
|
||||
/// Size of the page number and page info text, points.
|
||||
pub const PAGE_TEXT_PT: f64 = 9.0;
|
||||
|
||||
/// Lightroom's Image Settings panel.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -85,11 +93,18 @@ impl ImageSettings {
|
||||
pub struct PageStyle {
|
||||
/// Page background colour, sRGB.
|
||||
pub background: [u8; 3],
|
||||
/// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them):
|
||||
/// the page number at the bottom right of each page.
|
||||
pub page_numbers: bool,
|
||||
/// The print settings (sharpening, profile, resolution) at the bottom left of each page.
|
||||
pub page_info: bool,
|
||||
/// Marks at the corners of each photo to cut along.
|
||||
pub crop_marks: bool,
|
||||
}
|
||||
|
||||
impl Default for PageStyle {
|
||||
fn default() -> Self {
|
||||
Self { background: [255; 3] }
|
||||
Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
|
||||
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
|
||||
}
|
||||
|
||||
/// Whether a layout style prints the Page Options (page numbers, page info, crop marks):
|
||||
/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead.
|
||||
pub fn has_page_options(style: Style) -> bool {
|
||||
!style.is_package()
|
||||
}
|
||||
|
||||
/// The colour of crop marks and page text on `background`: black, or white on a dark page.
|
||||
pub fn mark_ink(background: [u8; 3]) -> [u8; 3] {
|
||||
let [r, g, b] = background.map(f64::from);
|
||||
if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] }
|
||||
}
|
||||
|
||||
/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer
|
||||
/// where the resolution is; this print goes to a PDF).
|
||||
pub fn page_info_text(settings: &PrintSettings) -> String {
|
||||
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
|
||||
let c = &settings.job.color;
|
||||
let media = match c.media {
|
||||
PrintMedia::Matte => "Matte",
|
||||
PrintMedia::Glossy => "Glossy",
|
||||
};
|
||||
let sharpen = match c.sharpen {
|
||||
PrintSharpen::Off => "Off".to_string(),
|
||||
PrintSharpen::Low => format!("Low ({media})"),
|
||||
PrintSharpen::Standard => format!("Standard ({media})"),
|
||||
PrintSharpen::High => format!("High ({media})"),
|
||||
};
|
||||
let profile = match &c.profile {
|
||||
PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(),
|
||||
PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(),
|
||||
PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()),
|
||||
};
|
||||
format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi())
|
||||
}
|
||||
|
||||
// ---- placement ------------------------------------------------------------------------------
|
||||
|
||||
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
|
||||
@@ -355,8 +405,8 @@ impl CellStyle {
|
||||
}
|
||||
|
||||
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
|
||||
/// asked), then the inner stroke on top of it.
|
||||
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
|
||||
/// asked), then the inner stroke on top of it. Returns where the photo landed.
|
||||
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<PxRect, String> {
|
||||
// The border may not eat the cell: at least one pixel of photo area stays.
|
||||
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
|
||||
let (ax, ay) = (cell.x + border, cell.y + border);
|
||||
@@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
|
||||
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
|
||||
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
|
||||
}
|
||||
Ok(())
|
||||
Ok(PxRect { x: dx, y: dy, w, h })
|
||||
}
|
||||
|
||||
/// What the Page Options draw on every page, in page pixels.
|
||||
struct Marks {
|
||||
/// Crop mark length, gap from the corner and weight.
|
||||
crop: Option<(usize, usize, usize)>,
|
||||
numbers: bool,
|
||||
info: Option<String>,
|
||||
ink: [u16; 3],
|
||||
text_px: f32,
|
||||
/// The printable area: the text sits in the margin below it, aligned with its sides.
|
||||
area: Option<PxRect>,
|
||||
}
|
||||
|
||||
impl Marks {
|
||||
fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self {
|
||||
let on = has_page_options(settings.layout.style);
|
||||
let page = &settings.page;
|
||||
let px = |inches: f64| (inches * ppi).round() as usize;
|
||||
Marks {
|
||||
crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))),
|
||||
numbers: on && page.page_numbers,
|
||||
info: (on && page.page_info).then(|| page_info_text(settings)),
|
||||
ink: rec2020_from_srgb8(mark_ink(page.background)),
|
||||
text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32,
|
||||
area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the marks of page `number` (one-based) around `photos`, the photos on it.
|
||||
fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) {
|
||||
if let Some((len, gap, weight)) = self.crop {
|
||||
for r in photos {
|
||||
crop_marks(page, r, len, gap, weight, photos, self.ink);
|
||||
}
|
||||
}
|
||||
let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h });
|
||||
let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32);
|
||||
// centred in the bottom margin, and on the paper when the margin is thinner than the text
|
||||
let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0));
|
||||
if self.numbers
|
||||
&& let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS)
|
||||
{
|
||||
let x = ((area.x + area.w) as f32 - t.width).max(0.0);
|
||||
draw_text(page, &t, x, top(t.height), self.ink);
|
||||
}
|
||||
if let Some(info) = &self.info
|
||||
&& let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS)
|
||||
{
|
||||
draw_text(page, &t, area.x as f32, top(t.height), self.ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts
|
||||
/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo.
|
||||
fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) {
|
||||
let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64);
|
||||
let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64);
|
||||
let segments = [
|
||||
(x0 - g - l, y0, l, t),
|
||||
(x1 + g, y0, l, t),
|
||||
(x0 - g - l, y1 - t, l, t),
|
||||
(x1 + g, y1 - t, l, t),
|
||||
(x0, y0 - g - l, t, l),
|
||||
(x0, y1 + g, t, l),
|
||||
(x1 - t, y0 - g - l, t, l),
|
||||
(x1 - t, y1 + g, t, l),
|
||||
];
|
||||
let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h);
|
||||
for (sx, sy, sw, sh) in segments {
|
||||
for y in sy.max(0)..(sy + sh).min(page.h as i64) {
|
||||
for x in sx.max(0)..(sx + sw).min(page.w as i64) {
|
||||
let (x, y) = (x as usize, y as usize);
|
||||
if !on_photo(x, y) {
|
||||
page.fill_rect(x, y, 1, 1, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`).
|
||||
fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) {
|
||||
let (w, h) = (page.w, page.h);
|
||||
text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| {
|
||||
let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return };
|
||||
if gx >= w || gy >= h {
|
||||
return;
|
||||
}
|
||||
let i = (gy * w + gx) * 3;
|
||||
if let Some(px) = page.rgb.get_mut(i..i + 3) {
|
||||
let k = cov.clamp(0.0, 1.0);
|
||||
for (p, c) in px.iter_mut().zip(ink) {
|
||||
*p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the job ---------------------------------------------------------------------------------
|
||||
@@ -402,6 +550,7 @@ pub struct Prepared {
|
||||
ppi: f32,
|
||||
background: [u16; 3],
|
||||
style: CellStyle,
|
||||
marks: Marks,
|
||||
/// Print sharpening, applied to each photo once it's rendered at its print size.
|
||||
color: crate::print_color::PrintColorSettings,
|
||||
}
|
||||
@@ -430,6 +579,7 @@ impl Prepared {
|
||||
let mut out = Vec::with_capacity(self.pages.len());
|
||||
for (index, &(w, h)) in self.pages.iter().enumerate() {
|
||||
let mut page = Image16::filled(w, h, self.background)?;
|
||||
let mut placed = Vec::new();
|
||||
while let Some(item) = items.next_if(|i| i.page == index) {
|
||||
if !progress(done, &item.name) {
|
||||
return Err("Print cancelled".into());
|
||||
@@ -448,8 +598,9 @@ impl Prepared {
|
||||
}
|
||||
};
|
||||
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
|
||||
draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
|
||||
placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?);
|
||||
}
|
||||
self.marks.draw(&mut page, &placed, index + 1);
|
||||
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
|
||||
}
|
||||
if !progress(done, "") {
|
||||
@@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI
|
||||
ppi: ppi as f32,
|
||||
background: rec2020_from_srgb8(settings.page.background),
|
||||
style,
|
||||
marks: Marks::of(&settings, ppi, pw, ph),
|
||||
color: settings.job.color.clone(),
|
||||
})
|
||||
}
|
||||
@@ -825,6 +977,94 @@ mod tests {
|
||||
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
|
||||
}
|
||||
|
||||
/// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the
|
||||
/// margins are bare background unless something draws there).
|
||||
fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec<PageRaster> {
|
||||
let ids = first_ids(s, 2);
|
||||
let mut set = settings(style);
|
||||
if style.is_package() {
|
||||
set.layout.add_cell(3.0, 5.0).unwrap();
|
||||
set.layout.new_page().unwrap();
|
||||
let id = set.layout.add_cell(3.0, 5.0).unwrap();
|
||||
set.layout.set_cell_page(id, 1).unwrap();
|
||||
}
|
||||
set.image.zoom_to_fill = true;
|
||||
set.page = page;
|
||||
prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap()
|
||||
}
|
||||
|
||||
fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] {
|
||||
let i = (y * p.width as usize + x) * 3;
|
||||
[p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
|
||||
}
|
||||
|
||||
/// The pixels of `x0..x1` x `y0..y1` that are not the white background.
|
||||
fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
|
||||
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_marks_sit_outside_each_photo_corner() {
|
||||
let mut s = Session::with_demo();
|
||||
let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default());
|
||||
assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked");
|
||||
let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() });
|
||||
// The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner,
|
||||
// along each edge of the photo.
|
||||
for p in &pages {
|
||||
for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] {
|
||||
assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})");
|
||||
}
|
||||
for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] {
|
||||
assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
// a dark page gets white marks
|
||||
let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() });
|
||||
assert_eq!(rgb(&dark[0], 30, 50), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_numbers_and_info_print_in_the_bottom_margin() {
|
||||
let mut s = Session::with_demo();
|
||||
let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() });
|
||||
// bottom right: the number, different on each page; nothing at the bottom left
|
||||
let (right, left) = ((200, 400), (0, 200));
|
||||
for p in &numbered {
|
||||
assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number");
|
||||
assert_eq!(inked(p, left.0, left.1, 550, 600), 0);
|
||||
assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top");
|
||||
}
|
||||
let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::<Vec<_>>();
|
||||
assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently");
|
||||
let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() });
|
||||
for p in &info {
|
||||
assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line");
|
||||
}
|
||||
// the packages have no Page Options in Lightroom: nothing is drawn
|
||||
let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() };
|
||||
let package = page_options_pages(&mut s, Style::PicturePackage, all);
|
||||
assert_eq!(package.len(), 2);
|
||||
for p in &package {
|
||||
assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_info_names_the_sharpening_profile_and_resolution() {
|
||||
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
|
||||
let mut set = PrintSettings::default();
|
||||
let line = page_info_text(&set);
|
||||
assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi");
|
||||
set.job.color.sharpen = PrintSharpen::Off;
|
||||
set.job.color.media = PrintMedia::Matte;
|
||||
set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() };
|
||||
set.job.ppi = 300.0;
|
||||
assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi");
|
||||
set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb };
|
||||
assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_and_hostile_settings_are_refused() {
|
||||
let mut s = Session::with_demo();
|
||||
|
||||
@@ -916,7 +916,8 @@ fn plan_rename_core(
|
||||
) -> Vec<RenamePlan> {
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
let mut out = Vec::new();
|
||||
for (seq, p) in (start..).zip(photos) {
|
||||
for (i, p) in photos.iter().enumerate() {
|
||||
let seq = start.saturating_add(i);
|
||||
let want = expand(template, p, seq);
|
||||
let (stem, ext) = split_ext(&want);
|
||||
let (stem, ext) = (stem.to_string(), ext.to_string());
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//! A sidecar is written only beside an original that is an existing file at an absolute path;
|
||||
//! otherwise the save fails.
|
||||
//!
|
||||
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
|
||||
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
|
||||
@@ -632,6 +634,10 @@ impl Session {
|
||||
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
// A relative path would be taken from the working directory.
|
||||
if Path::new(orig).is_relative() || !Path::new(orig).is_file() {
|
||||
return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name)));
|
||||
}
|
||||
let naming = owners.naming(p, self.xmp.naming);
|
||||
let preferred = sidecar_path(orig, naming);
|
||||
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
|
||||
@@ -786,6 +792,30 @@ impl Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn sidecars_are_written_only_beside_an_existing_original() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap();
|
||||
let originals = [
|
||||
(r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false),
|
||||
(dir.join("IMG_2.jpg").to_string_lossy().to_string(), false),
|
||||
(dir.join("IMG_1.jpg").to_string_lossy().to_string(), true),
|
||||
];
|
||||
for (original, written) in originals {
|
||||
let _ = std::fs::remove_file(dir.join("IMG_1.xmp"));
|
||||
let mut s = Session::new();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}");
|
||||
assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}");
|
||||
assert!(!dir.join("IMG_2.xmp").exists(), "{original}");
|
||||
assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
|
||||
/// gives the photo its keywords as paths.
|
||||
#[test]
|
||||
|
||||
@@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma:
|
||||
let my0 = (y - pad).floor().max(-pad);
|
||||
let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad);
|
||||
let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad);
|
||||
if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() {
|
||||
if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize);
|
||||
|
||||
@@ -115,7 +115,7 @@ fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
|
||||
Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None),
|
||||
Value::String(s) => {
|
||||
let h = s.strip_prefix('#').unwrap_or(s);
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(bad());
|
||||
}
|
||||
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad);
|
||||
@@ -747,6 +747,8 @@ mod tests {
|
||||
json!({"repeat": 1}),
|
||||
json!({"fadeColor": "#12345"}),
|
||||
json!({"fadeColor": "#ééé"}),
|
||||
json!({"fadeColor": "#+f+f+f"}),
|
||||
json!({"fadeColor": "-12345"}),
|
||||
json!({"fadeColor": [300, 0, 0]}),
|
||||
json!({"fadeColor": [1, 2]}),
|
||||
json!({"speed": 2}),
|
||||
|
||||
@@ -1024,21 +1024,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
let mut written = Vec::new();
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
|
||||
written.push(p.to_string());
|
||||
Ok(())
|
||||
// the writer lives in a block of its own, so its borrow of `written` has ended by the time
|
||||
// the assertion below reads it
|
||||
let files = {
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
|
||||
written.push(p.to_string());
|
||||
Ok(())
|
||||
};
|
||||
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
|
||||
assert!(err.contains("no folder on disk"), "{err}");
|
||||
// without stopping: one error per photo, nothing written
|
||||
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap()
|
||||
};
|
||||
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
|
||||
assert!(err.contains("no folder on disk"), "{err}");
|
||||
// without stopping: one error per photo, nothing written
|
||||
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
for (f, id) in files.iter().zip(&ids) {
|
||||
assert_eq!(f["photo"], id.0, "{f}");
|
||||
assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}");
|
||||
}
|
||||
drop(write);
|
||||
assert!(written.is_empty(), "{written:?}");
|
||||
|
||||
// a photo on disk whose folder is gone (moved, an unplugged drive)
|
||||
|
||||
@@ -778,6 +778,50 @@ fn assisted_culling_groups_bursts_and_scores_focus() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of
|
||||
/// every month, including those with fewer than 31 days.
|
||||
#[test]
|
||||
fn assisted_culling_groups_bursts_across_month_end() {
|
||||
let dir = temp_dir("cull-month-end");
|
||||
for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(w, h);
|
||||
let data: Vec<[u8; 4]> = img
|
||||
.data
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
|
||||
[e(c[0]), e(c[1]), e(c[2]), 255]
|
||||
})
|
||||
.collect();
|
||||
let png = lightcraft_codecs::encode_png(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join(name), png).unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
|
||||
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
|
||||
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
|
||||
let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));
|
||||
for (before, after) in [
|
||||
("2026-01-31T23:59:59", "2026-02-01T00:00:00"),
|
||||
("2026-02-28T23:59:59", "2026-03-01T00:00:00"),
|
||||
("2026-04-30T23:59:59", "2026-05-01T00:00:00"),
|
||||
("2026-06-30T23:59:59", "2026-07-01T00:00:00"),
|
||||
("2026-09-30T23:59:59", "2026-10-01T00:00:00"),
|
||||
("2026-11-30T23:59:59", "2026-12-01T00:00:00"),
|
||||
("2026-12-31T23:59:59", "2027-01-01T00:00:00"),
|
||||
] {
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap();
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap();
|
||||
let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
|
||||
#[test]
|
||||
fn dust_spots_are_found_and_healed() {
|
||||
|
||||
@@ -336,3 +336,23 @@ fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() {
|
||||
drop(reopened);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A huge `start` is a validation error, not an arithmetic overflow in the numbering.
|
||||
#[test]
|
||||
fn rename_huge_start_is_a_bad_param() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
let names_before: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
|
||||
for cmd in ["photo.renamePreview", "photo.rename"] {
|
||||
let e = s.execute(cmd, &json!({"template": "{seq}", "start": u64::MAX, "ids": ids})).unwrap_err();
|
||||
assert!(e.to_string().contains("start out of range"), "{cmd}: {e}");
|
||||
}
|
||||
let names_after: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
|
||||
assert_eq!(names_before, names_after, "a rejected rename changes nothing");
|
||||
// the last start whose whole sequence fits is accepted, one more is not
|
||||
let fits = usize::MAX as u64 - 2;
|
||||
let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits, "ids": ids}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids}));
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
}
|
||||
|
||||
@@ -142,6 +142,25 @@ fn preferences_persist_with_the_library() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_merge_settings_persist_with_the_library() {
|
||||
let lib = temp_dir("merge");
|
||||
let opts = json!({"align": false, "deghost": "high"});
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.last_merge.insert("merge.hdr".into(), opts.clone());
|
||||
s.save_prefs().unwrap();
|
||||
s.close_library().unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts));
|
||||
assert!(!s.last_merge.contains_key("merge.panorama"));
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_copyright_and_creator_fill_gaps_on_import() {
|
||||
let dir = temp_dir("copyright");
|
||||
|
||||
@@ -341,3 +341,23 @@ fn every_print_command_is_registered_with_a_description() {
|
||||
assert!(c.params.len() > 3, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_options_are_print_settings_and_template_fields() {
|
||||
let mut s = Session::with_demo();
|
||||
let g = run(&mut s, "print.get", json!({}));
|
||||
assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false}));
|
||||
let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}}));
|
||||
assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false)));
|
||||
assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges");
|
||||
fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}}));
|
||||
fails(&mut s, "print.set", json!({"page": {"pageNumber": true}}));
|
||||
// a template keeps them
|
||||
run(&mut s, "print.template.save", json!({"name": "Marked"}));
|
||||
run(&mut s, "print.reset", json!({}));
|
||||
assert!(!s.print.page.crop_marks);
|
||||
run(&mut s, "print.template.apply", json!({"name": "Marked"}));
|
||||
assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info);
|
||||
let set = crate::find_command("print.set").unwrap();
|
||||
assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user