Merge remote-tracking branch 'origin/fix/colour-fit-lens-geometry' into feat/nikon-z6ii-camera-profile

# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/tests_xmp.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Michael Anderson
2026-10-15 00:55:18 -05:00
203 changed files with 17105 additions and 823 deletions
+2
View File
@@ -52,6 +52,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
[dev-dependencies]
# tests record why they skip (`cargo xtask ci` lists the skips)
lightcraft-testkit = { workspace = true }
# HDR JPEG XL tests read the file's colour encoding
jxl-oxide = { workspace = true }
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
+495 -37
View File
@@ -78,10 +78,41 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's
/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the
/// as-shot white, the camera transform at it and the fitted look.
/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from
/// `warp` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from
/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing.
pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> (Xy, CameraTransform, Option<CameraLook>) {
starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens))
pub(crate) fn starting_colour(
raw: &mut RawImage,
bytes: &[u8],
lens: Option<&lightcraft_develop::EmbeddedLens>,
warp: Option<&LensGeometry>,
) -> (Xy, CameraTransform, Option<CameraLook>) {
let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, warp), |raw, t| monochrome_look(raw, bytes, t, warp));
if look.is_none() && dng_tone_eligible(raw, &t) {
let look = fit_dng_tone(raw, bytes, &t, lens);
return (xy, t, look);
}
(xy, t, look)
}
/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range).
fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool {
curve.is_none_or(|c| {
(0..=32).all(|i| {
let x = i as f32 / 32.0;
(c.eval(x) - x).abs() < 0.005
})
})
}
/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no
/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering).
pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
let p = &raw.color.profile;
raw.format == RawFormat::Dng
&& !t.matrix_is_fallback
&& identity_tone_curve(p.tone_curve.as_ref())
&& p.look_table.is_none()
&& p.gain_table_map.is_none()
}
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
@@ -130,7 +161,7 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw));
// (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling, and Export as DNG
// takes the lens corrections off first)
let (_, t, look) = starting_colour(raw, bytes, None);
let (_, t, look) = starting_colour(raw, bytes, None, None);
dng_colour(&raw.color, &white, &t, look.as_ref())
}
@@ -232,7 +263,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
return (None, false);
}
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, lens) else { return (None, false) };
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) };
let black_and_white = monochrome(&reference);
// A camera profile pooled from many photos knows colours this photo shows too little of;
// try its colour first and fit tone/chroma per photo (picture styles vary).
@@ -389,6 +420,159 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool {
/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px);
/// 128 MP also admits 100 MP medium-format previews.
const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model
/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near
/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than
/// the plain render ([`clips_more`]).
fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option<CameraLook> {
// Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate.
let refuse = |why: &str| {
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
eprintln!("DNGTONE refused {why}");
}
None
};
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") };
// As the loader renders it: the profile's tables around the baseline exposure.
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) {
let gain = 2f32.powf(transform.baseline_exposure as f32);
sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain));
}
correct_vignetting(&mut sensor, lens, raw.orientation);
if (sensor.width, sensor.height) != (reference.width, reference.height) {
return refuse("preview");
}
if !structure_agrees(&sensor, &reference) {
return refuse("structure");
}
let unclipped = without_clipped(&sensor, &clipped);
let fit_on = unclipped.as_ref().unwrap_or(&sensor);
let colour = Some((Mat3::IDENTITY, None));
let look = TONE_FITS.iter().find_map(|&tone| {
[false, true]
.into_iter()
.find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }))
});
let Some(look) = look else { return refuse("fit") };
let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) {
Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0),
None => ToneMap::new(0.0, 0.0, 0.0),
};
let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
if clips_more(&sensor, &reference, &fitted, &plain) {
return refuse("clip");
}
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
eprintln!("DNGTONE accepted");
}
Some(look)
}
/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain
/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the
/// camera's look, one that clips what neither does loses highlight detail.
fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool {
let near_white = |tone: &ToneMap| {
let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count();
n as f64 / sensor.data.len().max(1) as f64
};
let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64;
near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE
}
/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the
/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the
/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice.
fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) {
let Some(vignette) = lens.and_then(|l| l.vignette) else { return };
let (w, h) = (sensor.width as f64, sensor.height as f64);
let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) };
let width = sensor.width.max(1);
for (i, p) in sensor.data.iter_mut().enumerate() {
let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h);
let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32;
*p = p.map(|v| v * gain);
}
}
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
/// small thumbnail is not fitted.
const MIN_PREVIEW_FRACTION: f64 = 0.9;
/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark
/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows.
const DARK_FLOOR: (f32, f32) = (0.004, 0.001);
/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's
/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits
/// that clip about 1% more than the camera does and passes the ones that clip like the camera).
const CLIP_TOLERANCE: f64 = 0.001;
/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach.
const MIN_STRUCTURE: f64 = 0.85;
/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance
/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3)
/// correlates better.
fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool {
let (w, h) = (sensor.width, sensor.height);
if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h {
return false;
}
let log = |img: &Rgb32f| -> Vec<f64> {
let l: Vec<f64> =
img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect();
(0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let (mut sum, mut n) = (0.0, 0.0);
for yy in y.saturating_sub(2)..(y + 3).min(h) {
for xx in x.saturating_sub(2)..(x + 3).min(w) {
if let Some(v) = l.get(yy * w + xx) {
sum += v;
n += 1.0;
}
}
}
sum / f64::max(n, 1.0)
})
.collect()
};
let (a, b) = (log(sensor), log(reference));
const M: usize = 3;
let corr = |dx: isize, dy: isize| -> f64 {
let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
for y in M..h - M {
for x in M..w - M {
let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy));
let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue };
n += 1.0;
sa += u;
sb += v;
saa += u * u;
sbb += v * v;
sab += u * v;
}
}
let var = (saa - sa * sa / n) * (sbb - sb * sb / n);
if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() }
};
let at_zero = corr(0, 0);
let (mut best, mut shift) = (at_zero, (0isize, 0isize));
for dy in -(M as isize)..=M as isize {
for dx in -(M as isize)..=M as isize {
let c = corr(dx, dy);
if c > best + 1e-9 {
(best, shift) = (c, (dx, dy));
}
}
}
at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1
}
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
/// and per proxy pixel whether any sensor sample it covers is clipped.
@@ -397,6 +581,7 @@ fn proxies(
bytes: &[u8],
transform: &CameraTransform,
size: usize,
min_fraction: f64,
lens: Option<&LensGeometry>,
) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
let edge = (2 * size).max(384) as u32;
@@ -405,8 +590,13 @@ fn proxies(
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
None,
)?;
let mut reference = decoded.to_working();
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
// a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any
if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0)
{
return None;
}
let mut reference = decoded.to_working();
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
return None;
}
@@ -622,11 +812,11 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeom
return None;
}
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, lens)?;
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?;
let to_camera = transform.matrix.inverse()?;
// clipped sensor pixels don't record the scene's colour (see `fit_look`)
let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor);
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?;
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None, false)?;
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
}
@@ -794,7 +984,7 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
/// colours (see [`EDGE_CONTRAST`]).
type Pairs = Vec<([f64; 3], [f64; 3])>;
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>, dark: bool) -> Option<(Pairs, Pairs)> {
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
return None;
}
@@ -813,7 +1003,8 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
if (0.015..=1.0).contains(&y) {
bright.push((input.map(f64::from), output.map(f64::from)));
}
if !colour_pair(*input, *output) {
let floors = if dark { DARK_FLOOR } else { (0.015, 0.004) };
if !colour_pair_above(*input, *output, floors) {
continue;
}
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
@@ -826,9 +1017,15 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi
/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting.
fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool {
colour_pair_above(input, output, (0.015, 0.004))
}
/// [`colour_pair`] with its lowest preview luminance and channel value given (`floors`: the usual 0.015 and 0.004, or
/// [`DARK_FLOOR`] for the tone-only fit of a DNG).
fn colour_pair_above(input: [f32; 3], output: [f32; 3], (ylo, clo): (f32, f32)) -> bool {
input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
&& output.iter().all(|v| *v > 0.004 && *v < 0.98)
&& (0.015..0.85).contains(&luminance_2020(output))
&& output.iter().all(|v| *v > clo && *v < 0.98)
&& (ylo..0.85).contains(&luminance_2020(output))
}
/// Fit tone independently of colour eligibility when enough dark JPEG signal is available.
@@ -965,6 +1162,8 @@ struct Attempt {
tone: ToneFit,
profile: bool,
away_from_edges: bool,
/// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG.
dark: bool,
}
/// The photo's look and the attempt that produced it: its own matrix (with and without a
@@ -981,7 +1180,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
order.iter().find_map(|&tone| {
profiles.iter().find_map(|&profile| {
[false, true].into_iter().find_map(|away_from_edges| {
let attempt = Attempt { tone, profile, away_from_edges };
let attempt = Attempt { tone, profile, away_from_edges, dark: false };
fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt))
})
})
@@ -990,7 +1189,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, attempt: Attempt) -> Option<CameraLook> {
let colour = if attempt.profile { colour.clone() } else { None };
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone)
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark)
}
fn fit_pairs_on(
@@ -999,10 +1198,11 @@ fn fit_pairs_on(
colour: Option<(Mat3, Option<HsvTable>)>,
edge_limit: Option<f32>,
tone_fit: ToneFit,
dark: bool,
) -> Option<CameraLook> {
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
// learn a new colour matrix. Still reject monochrome references.
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?;
let candidates = match colour {
Some(given) => vec![given],
None => {
@@ -1146,7 +1346,7 @@ fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<CameraLook> {
// as for a known colour model: enough signal for the tone, but no monochrome reference
let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit)?;
let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?;
let related = rank_correlation(&pairs);
if related < PARTIAL_MIN_RANK_CORRELATION {
if lightcraft_pipeline::profiling() {
@@ -1224,7 +1424,7 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
if !file_local_look(raw.format) {
return None;
}
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, lens)?;
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?;
if !monochrome(&reference) {
return None;
}
@@ -1255,7 +1455,7 @@ fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opti
fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
// every pair counts: the JPEG has no colour to require
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?;
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?;
let related = rank_correlation_ties(&pairs);
if related < PARTIAL_MIN_RANK_CORRELATION {
if lightcraft_pipeline::profiling() {
@@ -1349,6 +1549,42 @@ impl<'a> HueSat<'a> {
}
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
}
/// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through
/// [`HsvTable::apply_lanes`], with the guards as selects.
#[inline]
pub fn apply_lanes<const N: usize>(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] {
let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| {
let mut out = [[0.0f32; N]; 3];
for (row, out) in m.iter().zip(&mut out) {
for l in 0..N {
out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l];
}
}
out
};
let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb)));
let mut restored = rgb;
for l in 0..N {
let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]);
let (before, after) = (luminance_2020(input), luminance_2020(out));
let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite());
let w = restore_weight(after / before);
for (c, restored) in restored.iter_mut().enumerate() {
let full = out[c] * before / after;
let blend = input[c] + (full - input[c]) * w;
// (`w >= 1` and `w <= 0` exclude each other)
restored[l] = if untouched | (w <= 0.0) {
input[c]
} else if w >= 1.0 {
full
} else {
blend
};
}
}
restored
}
}
/// HSV hue (degrees) and saturation; `None` for black or non-finite colours.
@@ -1658,7 +1894,7 @@ mod tests {
fn colour_holdouts_stay_out_of_the_wider_tone_training() {
let (sensor, reference) = steep_toe_scene();
let fit = |reference: &Rgb32f| {
let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None).unwrap();
let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None, false).unwrap();
fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap()
};
let expected = fit(&reference);
@@ -1715,7 +1951,7 @@ mod tests {
*dst = [y * slope; 3];
assert_eq!(colour_pair(*src, *dst), i >= 600);
}
let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap();
let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None, false).unwrap();
let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap();
// Both sample sets start at a multiple of three, so this split matches
// the helper's separate colour and extra-sample indices.
@@ -1825,8 +2061,10 @@ mod tests {
color: ColorData::default(),
wb_multipliers: Some([1.0; 3]),
linearized: true,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata: lightcraft_meta::Metadata::default(),
lens_metadata: Default::default(),
};
let proxy = sensor_proxy(&raw, 2, 384).unwrap();
assert_eq!((proxy.width, proxy.height), (32, 32));
@@ -2016,7 +2254,7 @@ mod tests {
let mut pool = Vec::new();
for seed in 0..4 {
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0);
pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0);
}
let (matrix, _) = fit_profile(&pool).unwrap();
// the camera's colours (luminance-normalised: the tone curve sets brightness)
@@ -2120,6 +2358,51 @@ mod tests {
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
}
/// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey,
/// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table.
#[test]
fn hue_sat_lanes_match_one_by_one_bit_for_bit() {
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0;
let fitted = (0..72 * 5 * 5)
.map(|_| {
let r = next();
[unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0]
})
.collect();
let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true };
let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN];
let mut colours = Vec::new();
for a in special {
for b in special {
colours.extend(special.map(|c| [a, b, c]));
}
}
for _ in 0..60_000 {
let r = next();
let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize];
colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset));
}
for table in [fitted, saturating] {
let hue_sat = HueSat::new(&table).unwrap();
for chunk in colours.chunks(16) {
let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c])));
let out = hue_sat.apply_lanes(rgb);
for (l, p) in chunk.iter().enumerate() {
let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p));
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}");
}
}
}
}
#[test]
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
assert_eq!(restore_weight(0.0), 0.0);
@@ -2168,8 +2451,10 @@ mod tests {
color,
wb_multipliers: Some([2.0, 1.0, 1.5]),
linearized: false,
pixel_aspect: 1.0,
opcodes: Default::default(),
metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() },
lens_metadata: Default::default(),
}
}
@@ -2245,7 +2530,7 @@ mod tests {
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) =
(proxies(&raw, &bytes, &t, PROXY, None), proxies(&raw, &bytes, &t, PROXY, Some(&lens)))
(proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens)))
else {
continue;
};
@@ -2307,7 +2592,7 @@ mod tests {
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap();
let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default());
let (_, t, look) = starting_colour(&mut raw, &[], None);
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
assert!(look.is_none());
assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some));
assert!(!t.matrix_is_fallback);
@@ -2366,13 +2651,13 @@ mod tests {
#[test]
fn a_camera_outside_the_table_keeps_the_fallback() {
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default());
let (_, t, look) = starting_colour(&mut raw, &[], None);
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
assert!(look.is_none() && t.matrix_is_fallback);
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
// without a model there is no table row
let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default());
raw.metadata.model = None;
assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback);
assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback);
}
/// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is
@@ -2387,7 +2672,7 @@ mod tests {
.join("raw");
for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
lightcraft_testkit::skip!(Corpus, "{name} absent");
continue;
};
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
@@ -2398,7 +2683,7 @@ mod tests {
let measured = lightcraft_raw::spectral::find(make, model).is_some();
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
let before = raw.color.clone();
let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref());
let (_, t, fitted) = starting_colour(&mut raw, &bytes, None, lens.as_ref());
let spectral = !own && fitted.is_none() && measured;
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
assert_eq!(raw.color != before, spectral, "{name}");
@@ -2492,13 +2777,13 @@ mod tests {
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/cr3-canon-r100-raw.cr3");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
};
let raw = match lightcraft_raw::decode(&bytes) {
Ok(raw) => raw,
Err(lightcraft_raw::RawError::Unsupported(why)) => {
eprintln!("skip CR3 sensor decoder: {why}");
lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}");
return;
}
Err(error) => panic!("CR3 corpus failed to decode: {error}"),
@@ -2527,7 +2812,7 @@ mod tests {
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skip: {} absent", path.display());
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
}
Err(e) => panic!("{}: {e}", path.display()),
@@ -2601,7 +2886,7 @@ mod tests {
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/nef-nikon-d7500-lossless14.nef");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
@@ -2621,7 +2906,7 @@ mod tests {
let bytes = match std::fs::read(dir.join(name)) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skip: {name} absent");
lightcraft_testkit::skip!(Corpus, "{name} absent");
continue;
}
Err(e) => panic!("{name}: {e}"),
@@ -2642,7 +2927,7 @@ mod tests {
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/rw2-panasonic-fz1000m2-4x3.rw2");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
};
let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap();
@@ -2661,7 +2946,7 @@ mod tests {
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/arw-sony-a7rm2-12bit-uncompressed.arw");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
@@ -2751,6 +3036,36 @@ mod tests {
renders_as_shot(&dng);
}
/// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without
/// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking
/// it from the multipliers again leaves the merged DNG rendering as the tags do.
#[test]
fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() {
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of};
for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] {
let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default());
raw.wb_multipliers = Some(multipliers);
let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices");
let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance");
// the reciprocal of the multipliers, up to the scale a neutral is given in
let scale = neutral[1] * multipliers[1] as f64;
for i in 0..3 {
assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}");
}
let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone()));
merged.set_as_shot_multipliers(multipliers);
let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") };
let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None)));
for i in 0..3 {
assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb);
}
for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) {
assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix);
}
assert_eq!(a.baseline_exposure, b.baseline_exposure);
}
}
/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep.
fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook {
CameraLook {
@@ -2902,7 +3217,7 @@ mod tests {
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skip: {} absent", path.display());
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
continue;
}
Err(e) => panic!("{}: {e}", path.display()),
@@ -2913,7 +3228,7 @@ mod tests {
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look");
// the look fitted to the JPEG, as before this change
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, None).unwrap();
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap();
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap();
@@ -3209,7 +3524,7 @@ mod tests {
assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone));
// without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone)
let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap();
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false });
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false });
assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored");
}
@@ -3472,8 +3787,10 @@ mod tests {
color: ColorData::default(),
wb_multipliers: Some([1.0; 3]),
linearized: true,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() },
lens_metadata: Default::default(),
};
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
// the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG
@@ -3545,4 +3862,145 @@ mod tests {
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.camera_tone.is_some(), "no camera look fitted");
}
/// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right.
fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) {
let scene = |x: f32, y: f32| -> [f32; 3] {
let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32;
[l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))]
};
let camera = |p: [f32; 3]| {
let y = luminance_2020(p);
p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y)
};
let mut sensor = Rgb32f::new(w, h);
let mut reference = sensor.clone();
for y in 0..h {
for x in 0..w {
sensor.data[y * w + x] = scene(x as f32, y as f32);
reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32));
}
}
(sensor, reference)
}
/// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a
/// flat and a differently sized preview.
#[test]
fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() {
let (sensor, reference) = scene_pair(96, 64, 0);
assert!(structure_agrees(&sensor, &reference));
assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated");
assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed");
let mut stripes = reference.clone();
for (i, p) in stripes.data.iter_mut().enumerate() {
*p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] };
}
assert!(!structure_agrees(&sensor, &stripes), "a broken preview");
let mut flat = reference.clone();
flat.data.fill([0.2; 3]);
assert!(!structure_agrees(&sensor, &flat));
assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1));
}
/// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that
/// renders mid-grey brighter than the default tone map is followed.
#[test]
fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() {
let (mut sensor, mut reference) = scene_pair(96, 64, 0);
for image in [&mut sensor, &mut reference] {
for p in image.data.iter_mut().step_by(5) {
*p = [p[0] * 1.6, p[1], p[2] * 0.5];
}
}
let colour = Some((Mat3::IDENTITY, None));
let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true })
.expect("tone fitted");
assert_eq!(look.matrix, Mat3::IDENTITY);
assert!(look.hue_sat.is_none());
let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
let target = 1.0 - (-2.5f32 * 0.18).exp();
assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18));
assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18));
assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself");
}
/// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the
/// camera preview's, in one direction only: clipping what the camera clips is not refused.
#[test]
fn dng_highlight_gate_compares_near_white_shares() {
let mut sensor = Rgb32f::new(32, 32);
for (i, p) in sensor.data.iter_mut().enumerate() {
*p = [0.02 + 1.5 * i as f32 / 1024.0; 3];
}
let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0));
let mut white = Rgb32f::new(32, 32);
white.data.iter_mut().for_each(|p| *p = [1.0; 3]);
let mut black = Rgb32f::new(32, 32);
black.data.iter_mut().for_each(|p| *p = [0.1; 3]);
assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white");
assert!(!clips_more(&sensor, &black, &dark, &bright));
assert!(!clips_more(&sensor, &black, &bright, &bright));
assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much");
}
/// A dark scene's shadows below the usual training floor still reach the tone-only fit.
#[test]
fn dng_tone_fit_trains_on_dark_pixels() {
let mut sensor = Rgb32f::new(64, 64);
let mut reference = Rgb32f::new(64, 64);
for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() {
let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 };
*s = [v, v * 1.1, v * 0.9];
*r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)];
}
let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len());
let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len());
assert!(dark > usual, "{dark} vs {usual}");
}
/// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render.
#[test]
fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() {
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette};
let lens = EmbeddedLens {
vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }),
..Default::default()
};
let mut proxy = Rgb32f::new(96, 64);
proxy.data.fill([0.2; 3]);
correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal);
let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2);
assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}");
let mut plain = Rgb32f::new(96, 64);
plain.data.fill([0.2; 3]);
correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal);
assert!(plain.data.iter().all(|p| *p == [0.2; 3]));
}
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
/// identity, no look table; and no other raw format.
#[test]
fn dng_tone_gates_follow_the_files_own_tags() {
use lightcraft_raw::profile::{HsvTable, ToneCurve};
let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]);
let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() };
let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| {
let raw = raw_of(format, "Make", "Model", color);
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
dng_tone_eligible(&raw, &t)
};
assert!(eligible(RawFormat::Dng, color.clone()));
assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path");
assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep");
let mut identity = color.clone();
identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]);
assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone");
let mut curved = color.clone();
curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]);
assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept");
let mut looked = color;
looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0);
assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering");
}
}
+2 -2
View File
@@ -224,7 +224,7 @@ fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
/// The browsed photos among `ids` (default: the selection) become library photos.
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
if ids.is_empty() {
return Ok(json!({"added": 0}));
}
@@ -410,7 +410,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
if !dir.is_dir() {
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
}
let ids = s.targets(p);
let ids = s.targets(p)?;
if ids.is_empty() {
return Err(bad(C, "no photos to move: select some or give ids"));
}
+5 -5
View File
@@ -59,7 +59,7 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.convertToDng";
let ids = s.targets(p);
let ids = s.targets(p)?;
let mut ops = Vec::new();
let mut converted = Vec::new();
let mut skipped = Vec::new();
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
/// the library and stack it on top of the original. The host opens the file in the editor.
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.editExternal";
let mut ids = s.targets(p);
let mut ids = s.targets(p)?;
ids.dedup();
if ids.is_empty() {
return Err(bad(C, "no active photo"));
@@ -311,7 +311,7 @@ pub(crate) fn reload_ops(
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let ids = s.targets(p);
let ids = s.targets(p)?;
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
.iter()
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
@@ -340,7 +340,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.duplicate";
let mut made = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
let src = Path::new(path);
@@ -514,7 +514,7 @@ mod tests {
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
let f = root.join("raw/rw2-panasonic-gx80.rw2");
if !f.exists() {
eprintln!("skip: {} absent", f.display());
lightcraft_testkit::skip!(Corpus, "{} absent", f.display());
return;
}
let mut s = Session::new().with_fs();
+1 -1
View File
@@ -20,7 +20,7 @@ fn secs(iso: &str) -> Option<i64> {
}
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
+8 -7
View File
@@ -254,7 +254,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
}),
cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| {
let ids = s.targets(p);
let ids = s.targets(p)?;
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
@@ -304,7 +304,7 @@ pub fn specs() -> Vec<CommandSpec> {
let targets = match named_photos(s, p, C)? {
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
Some(ids) => ids,
None => s.targets(p),
None => s.targets(p)?,
};
let ops: Vec<Op> = targets
.into_iter()
@@ -811,7 +811,7 @@ pub fn specs() -> Vec<CommandSpec> {
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| *id != prev).collect();
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
@@ -838,7 +838,7 @@ pub fn specs() -> Vec<CommandSpec> {
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
clip = lightcraft_develop::extract_groups(&full, &only);
}
let ids = s.targets(p);
let ids = s.targets(p)?;
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
@@ -890,7 +890,7 @@ pub fn specs() -> Vec<CommandSpec> {
let delta = f64_req(p, "delta", c)?;
let label = format!("Quick Develop: {}", spec.label);
let ops: Vec<Op> = s
.targets(p)
.targets(p)?
.into_iter()
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter_map(|(id, d)| {
@@ -943,7 +943,7 @@ pub fn specs() -> Vec<CommandSpec> {
};
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|x| *x != reference).collect();
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
for id in ids {
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
@@ -978,7 +978,8 @@ pub fn specs() -> Vec<CommandSpec> {
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?;
let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0;
let ids = s.targets(p);
// `id` names the preset here: the photos are `ids`, else the selection
let ids = s.targets(&json!({"ids": p.get("ids")}))?;
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
+1 -1
View File
@@ -132,7 +132,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
let detector = detector(s).map_err(|e| e.into_error(C))?;
let started = Instant::now();
let (mut results, mut ops) = (Vec::new(), Vec::new());
for id in s.targets(p) {
for id in s.targets(p)? {
let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) {
Ok(r) => r,
Err(e) => {
+1 -1
View File
@@ -841,7 +841,7 @@ mod imp {
const C: &str = "faces.suggest";
let embedder = current(s, C)?;
collect(s);
let targets = s.targets(p);
let targets = s.targets(p)?;
let model_threshold = embedder.manifest().thresholds.match_cosine;
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v as f32);
let threshold = num("threshold").or(model_threshold).unwrap_or(DEFAULT_THRESHOLD);
+1 -1
View File
@@ -115,7 +115,7 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "metadata.reverseGeocode";
let overwrite = bool_or(p, "overwrite", false);
let ids = s.targets(p);
let ids = s.targets(p)?;
let todo: Vec<(PhotoId, LatLon)> = ids
.iter()
.filter_map(|id| {
+3 -3
View File
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
return Ok(json!({"changed": 0}));
};
let ids = s.targets(p);
let ids = s.targets(p)?;
let all = !ids.is_empty()
&& ids.iter().all(|id| {
s.catalog
@@ -288,7 +288,7 @@ pub fn specs() -> Vec<CommandSpec> {
always,
|s, p| {
let mut current: Vec<String> = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
if let Some(ph) = s.catalog.photo(id) {
current.extend(ph.meta.keywords.iter().cloned());
}
@@ -336,7 +336,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some(parent) => format!("{parent}|{name}"),
None => name,
});
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() };
let op = s
.catalog
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
+21 -16
View File
@@ -182,7 +182,7 @@ fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize>
/// Apply one op per target as a single undo step.
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
let targets = s.targets(p);
let targets = s.targets(p)?;
let ops: Vec<Op> = targets.iter().filter_map(|id| f(*id)).collect();
let n = ops.len();
if n > 0 {
@@ -194,7 +194,7 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
let mut ops = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
if rating != old {
@@ -213,7 +213,7 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step.
fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> {
let mut ops = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag;
let flag = step(old);
if flag != old {
@@ -258,7 +258,7 @@ fn row_step(p: &Value) -> isize {
}
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
let targets = s.targets(p);
let targets = s.targets(p)?;
let mut ops = Vec::new();
for id in targets {
if let Some(d) = s.develop_of(id) {
@@ -277,7 +277,7 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
}
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let targets = s.targets(p);
let targets = s.targets(p)?;
let mut ops = Vec::new();
for id in targets {
if let Some(d) = s.develop_of(id) {
@@ -883,7 +883,7 @@ pub fn specs() -> Vec<CommandSpec> {
// ---- delete / restore
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let before = s.visible_cloned();
let targets = s.targets(p);
let targets = s.targets(p)?;
let n = mark_deleted(s, &targets, "Delete")?;
select_after_deletion(s, &before);
Ok(json!({"changed": n}))
@@ -912,7 +912,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
let before = s.visible_cloned();
let t = s.targets(p);
let t = s.targets(p)?;
let op = s.catalog.delete_photos_permanently_ops(&t);
s.commit("Delete Permanently", op)?;
select_after_deletion(s, &before);
@@ -929,7 +929,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
let strs =
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
let targets = s.targets(p);
let targets = s.targets(p)?;
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
None => None,
@@ -1027,8 +1027,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
let folder = bool_or(p, "folder", false);
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
let (photos, skipped) =
if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) };
let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) };
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
s.commit(
@@ -1268,7 +1267,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_library_selection,
|s, p| {
let id = album_param(p, "id", "album.addPhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let targets = ids_param(p).unwrap_or_else(|| s.selected());
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
@@ -1276,8 +1275,10 @@ pub fn specs() -> Vec<CommandSpec> {
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
let mut photos = al.photos.clone();
let before = photos.len();
// looked up, not searched for: adding thousands to a big album stays linear
let mut held: std::collections::HashSet<PhotoId> = photos.iter().copied().collect();
for t in targets {
if !photos.contains(&t) {
if held.insert(t) {
photos.push(t);
}
}
@@ -1295,7 +1296,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
has_library_selection,
|s, p| {
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?;
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?;
let (targets, skipped) = targets;
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
let id = match target.or_else(|| s.catalog.quick_collection()) {
@@ -1308,11 +1309,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
};
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
// looked up, not searched for: thousands toggled in a big album stay linear
let mut held: std::collections::HashSet<PhotoId> = al.photos.iter().copied().collect();
let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t));
let photos: Vec<PhotoId> = if all_in {
let targets: std::collections::HashSet<PhotoId> = targets.iter().copied().collect();
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
} else {
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect()
};
let name = al.name.clone();
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
@@ -1454,7 +1458,7 @@ pub fn specs() -> Vec<CommandSpec> {
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
},
};
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let targets = ids_param(p).unwrap_or_else(|| s.selected());
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
if al.is_smart() {
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
@@ -1463,6 +1467,7 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("album.removePhotos", "album folders don't hold photos"));
}
let name = al.name.clone();
let targets: std::collections::HashSet<PhotoId> = targets.into_iter().collect();
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
let removed = al.photos.len().saturating_sub(photos.len());
if removed > 0 {
+3
View File
@@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
imported.push(json!({"id": id, "name": display, "group": group}));
}
s.save_prefs()?;
// presets that name these profiles use them now
crate::profile_refs::refresh(s);
Ok(json!({"imported": imported, "failed": failed}))
}
@@ -160,6 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
lightcraft_pipeline::lut::unregister(&id);
s.save_prefs()?;
crate::profile_refs::refresh(s);
Ok(json!({"deleted": id}))
}),
]
+3 -3
View File
@@ -10,7 +10,7 @@ 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))
Ok((s.targets(p)?, template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
@@ -96,7 +96,7 @@ fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
let mut ops = Vec::new();
let mut photos = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
let Some(ph) = s.catalog.photo(id) else { continue };
if ph.meta.gps.is_some() && !replace {
has_gps += 1;
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso};
let c = "photo.setCaptureTime";
let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
if targets.is_empty() {
return Err(bad(c, "no photos"));
}
+2 -2
View File
@@ -20,7 +20,7 @@ fn can_merge(s: &Session) -> std::result::Result<(), String> {
fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
let (kind, finish) = crate::merge::parse(id, p)?;
let ids: Vec<PhotoId> = s.targets(p);
let ids: Vec<PhotoId> = s.targets(p)?;
if !bool_or(p, "preview", false) {
return s.merge_now(kind, finish, &ids);
}
@@ -45,7 +45,7 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
fn run_batch(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "merge.hdrBatch";
let (kind, finish) = crate::merge::parse("merge.hdr", p)?;
let ids: Vec<PhotoId> = s.targets(p);
let ids: Vec<PhotoId> = s.targets(p)?;
let sets = s.catalog.bracket_sets(&ids);
if sets.is_empty() {
return Err(bad(ID, crate::merge::NO_BRACKET_SETS));
+3 -1
View File
@@ -34,6 +34,7 @@ mod organize;
mod prefs;
mod preset_files;
pub mod previews;
mod print;
mod query;
pub mod recheck;
mod slideshow;
@@ -109,7 +110,7 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
/// can take (issue #684; Add to My Photos first).
pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
if s.selected().iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
}
/// Why an album command refused a Local-only selection.
pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)";
@@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(prefs::specs());
v.extend(slideshow::specs());
v.extend(export::specs());
v.extend(print::specs());
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
+7 -7
View File
@@ -22,7 +22,7 @@ fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec<StackId> {
fn in_stack(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
if stacks_of(s, &s.selected()).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
}
fn has_stacks(s: &Session) -> std::result::Result<(), String> {
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"",
has_selection,
|s, p| {
let targets = s.targets(p);
let targets = s.targets(p)?;
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
let mut made = Vec::new();
for t in targets {
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged",
has_selection,
|s, p| {
let targets = s.targets(p);
let targets = s.targets(p)?;
let top = p
.get("top")
.and_then(Value::as_u64)
@@ -153,11 +153,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| {
let ops = s.catalog.ungroup_ops(&s.targets(p));
let ops = s.catalog.ungroup_ops(&s.targets(p)?);
Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?}))
}),
cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| {
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p));
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)?);
Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?}))
}),
cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| {
@@ -189,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
let stacks = stacks_of(s, &s.targets(p));
let stacks = stacks_of(s, &s.targets(p)?);
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
let collapsed = bool_or(p, "collapsed", !first);
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
@@ -212,7 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| {
let gap = f64_or(p, "gap", 60.0).max(0.0);
let explicit = p.get("ids").is_some();
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
let groups = s.catalog.auto_stack_groups(&pool, gap);
let photos: usize = groups.iter().map(Vec::len).sum();
if bool_or(p, "preview", false) {
+12 -2
View File
@@ -57,10 +57,20 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(g) = &group {
it.preset.group = g.clone();
}
// the profile it names (issue #1001): one the user imported, or ours for a basic one
let mut profile = Value::Null;
if let Some((r, _)) = crate::profile_refs::apply(&mut it.preset.settings, &s.lut_profiles) {
let look = it.preset.settings["profile"]["look"].clone();
it.unmapped.retain(|u| u != "Look");
it.unmapped.extend(crate::profile_refs::report(&look, &r));
profile = json!({"name": look["name"], "id": it.preset.settings["profile"].get("id")});
}
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
match id {
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
Some(id) => {
imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped, "profile": profile}))
}
None => skipped += 1,
}
}
@@ -108,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Presets",
[],
None,
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups); a profile the preset names applies when it was imported with profile.import (before or after) → {imported: [{id,name,group,file,unmapped,profile: {name, id}|null}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
always,
import
),
+3 -3
View File
@@ -152,7 +152,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "a preview build is already running"));
}
// explicit ids, else the selection, else everything in view
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
s.preview_build = Some(state.clone());
@@ -324,7 +324,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
return Err(bad(C, "a preview build is already running"));
}
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };
let mut jobs: Vec<SmartJob> = Vec::new();
for id in ids {
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
@@ -503,7 +503,7 @@ pub fn specs() -> Vec<CommandSpec> {
smart_location
),
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
let id = s.targets(p)?.first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(),
_ => true,
+353
View File
@@ -0,0 +1,353 @@
//! `print.*`: the Print module as commands.
//!
//! The module's state is [`Session::print`] (layout, image settings, page, guides, job), so the
//! app, CLI, control channel and MCP share one print. Lengths are inches in page space (origin at
//! the top-left of the page as printed); cell ids and photo ids are numbers. Nothing here touches
//! the catalog, so none of it is journaled or undoable; saved templates live in prefs.json.
//!
//! * `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`
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::{Result, Session};
fn rect_json(r: &Rect) -> Value {
json!({"x": r.x, "y": r.y, "w": r.w, "h": r.h})
}
/// The state, with what a front end needs to draw it (page size, printable area, grid cells).
fn state(s: &Session, photos: usize) -> Value {
let l = &s.print.layout;
let mut out = json!({
"settings": s.print,
"papers": paper_names().filter_map(|n| paper_size(n).map(|(w, h)| json!({"name": n, "width": w, "height": h}))).collect::<Vec<_>>(),
"overlaps": l.has_overlaps(),
"photos": photos,
});
if let (Ok((w, h)), Ok(area), Some(o)) = (l.page_size(), l.printable(), out.as_object_mut()) {
o.insert("pageWidth".into(), json!(w));
o.insert("pageHeight".into(), json!(h));
o.insert("printable".into(), rect_json(&area));
o.insert("pageCount".into(), json!(l.page_count(photos)));
o.insert("cellsPerPage".into(), json!(l.cells_per_page()));
if !l.style.is_package() {
o.insert("gridCells".into(), json!(l.grid_rects().unwrap_or_default().iter().map(rect_json).collect::<Vec<_>>()));
}
}
out
}
fn reply(s: &Session) -> Result<Value> {
Ok(state(s, s.targets(&Value::Null)?.len()))
}
/// Applies `patch` over `dst`: objects merge key by key, anything else replaces.
fn merge(dst: &mut Value, patch: &Value, depth: usize) {
match (dst, patch) {
(Value::Object(d), Value::Object(p)) if depth < 8 => {
for (k, v) in p {
match d.get_mut(k) {
Some(slot) => merge(slot, v, depth + 1),
None => {
d.insert(k.clone(), v.clone());
}
}
}
}
(d, p) => *d = p.clone(),
}
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state(s, s.targets(p)?.len()))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.set";
let patch = p.as_object().ok_or_else(|| bad(ID, "params must be an object"))?;
if let Some(k) = patch.keys().find(|k| !matches!(k.as_str(), "layout" | "image" | "page" | "guides" | "job")) {
return Err(bad(ID, format!("unknown parameter `{k}` (one of layout, image, page, guides, job)")));
}
if let Some(l) = patch.get("layout").and_then(Value::as_object)
&& let Some(k) = l.keys().find(|k| matches!(k.as_str(), "cells" | "pages" | "nextId"))
{
return Err(bad(ID, format!("`layout.{k}` is changed with the print.cell.* and print.layout.* commands")));
}
let mut value = serde_json::to_value(&s.print).map_err(|e| bad(ID, e.to_string()))?;
merge(&mut value, p, 0);
let mut next: PrintSettings = serde_json::from_value(value).map_err(|e| bad(ID, e.to_string()))?;
// A new paper, orientation or margin may leave cells outside the page: pull them back.
if next.layout.page.printable().is_ok() {
next.layout.fit_cells().map_err(|e| bad(ID, e))?;
}
next.validate().map_err(|e| bad(ID, e))?;
s.print = next;
reply(s)
}
fn reset(s: &mut Session, _: &Value) -> Result<Value> {
s.print = PrintSettings::default();
reply(s)
}
fn cell_id(p: &Value, id: &str) -> Result<u32> {
p.get("id").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(id, "missing cell `id`"))
}
fn opt_f64(p: &Value, key: &str, id: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_f64().map(Some).ok_or_else(|| bad(id, format!("`{key}` must be a number"))),
}
}
/// Snaps `r` (a cell being moved or resized) when the call asks for it.
fn snapped(l: &Layout, guides: &crate::print::layout::Guides, id: u32, r: Rect, p: &Value, resize: bool, cmd: &str) -> Result<Rect> {
let on = match p.get("snap") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`snap` must be true or false")),
};
if !on || guides.snap == Snap::Off {
return Ok(r);
}
let threshold = opt_f64(p, "threshold", cmd)?.unwrap_or(0.1);
let f = if resize { Layout::snap_resize } else { Layout::snap_move };
f(l, id, r, guides.snap, guides.grid_size, threshold).map_err(|e| bad(cmd, e))
}
fn cell_add(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.cell.add";
let (w, h) = (f64_req(p, "width", ID)?, f64_req(p, "height", ID)?);
let mut next = s.print.layout.clone();
let id = next.add_cell(w, h).map_err(|e| bad(ID, e))?;
if let (Some(x), Some(y)) = (opt_f64(p, "x", ID)?, opt_f64(p, "y", ID)?) {
if let Some(page) = p.get("page").and_then(Value::as_u64) {
let page = u32::try_from(page).map_err(|_| bad(ID, "`page` is too large"))?;
next.set_cell_page(id, page).map_err(|e| bad(ID, e))?;
}
next.move_cell(id, x, y).map_err(|e| bad(ID, e))?;
}
if let Some(v) = p.get("photo").filter(|v| !v.is_null()) {
let photo = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id"))?;
next.set_cell_photo(id, Some(photo)).map_err(|e| bad(ID, e))?;
}
s.print.layout = next;
let cell = s.print.layout.cell(id).cloned();
Ok(json!({"id": id, "cell": cell, "pages": s.print.layout.pages}))
}
fn cell_move(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.cell.move";
let id = cell_id(p, ID)?;
let (x, y) = (f64_req(p, "x", ID)?, f64_req(p, "y", ID)?);
let mut next = s.print.layout.clone();
let size = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?;
let target = snapped(&next, &s.print.guides, id, Rect::new(x, y, size.w, size.h), p, false, ID)?;
let r = next.move_cell(id, target.x, target.y).map_err(|e| bad(ID, e))?;
s.print.layout = next;
Ok(json!({"id": id, "rect": r}))
}
fn cell_resize(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.cell.resize";
let id = cell_id(p, ID)?;
let mut next = s.print.layout.clone();
let cur = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?;
let want = Rect::new(
opt_f64(p, "x", ID)?.unwrap_or(cur.x),
opt_f64(p, "y", ID)?.unwrap_or(cur.y),
opt_f64(p, "width", ID)?.unwrap_or(cur.w),
opt_f64(p, "height", ID)?.unwrap_or(cur.h),
);
let target = snapped(&next, &s.print.guides, id, want, p, true, ID)?;
let r = next.resize_cell(id, target).map_err(|e| bad(ID, e))?;
s.print.layout = next;
Ok(json!({"id": id, "rect": r}))
}
fn cell_delete(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.cell.delete";
let id = cell_id(p, ID)?;
s.print.layout.delete_cell(id).map_err(|e| bad(ID, e))?;
Ok(json!({"deleted": id, "cells": s.print.layout.cells.len()}))
}
fn cell_stack(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.cell.stack";
let id = cell_id(p, ID)?;
let how = match p.get("order").and_then(Value::as_str) {
Some("toBack") => Stack::ToBack,
Some("backward") => Stack::Backward,
Some("forward") => Stack::Forward,
Some("toFront") => Stack::ToFront,
_ => return Err(bad(ID, "`order` must be toBack, backward, forward or toFront")),
};
s.print.layout.restack(id, how).map_err(|e| bad(ID, e))?;
Ok(json!({"order": s.print.layout.cells.iter().map(|c| c.id).collect::<Vec<_>>()}))
}
fn cell_photo(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.cell.photo";
let id = cell_id(p, ID)?;
let photo = match p.get("photo") {
None | Some(Value::Null) => None,
Some(v) => {
let n = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id or null"))?;
if s.catalog.photo(lightcraft_catalog::PhotoId(n)).is_none() {
return Err(bad(ID, format!("no photo {n}")));
}
Some(n)
}
};
s.print.layout.set_cell_photo(id, photo).map_err(|e| bad(ID, e))?;
Ok(json!({"id": id, "photo": photo}))
}
fn cell_page(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.cell.page";
let id = cell_id(p, ID)?;
let page = p.get("page").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(ID, "missing zero-based `page`"))?;
s.print.layout.set_cell_page(id, page).map_err(|e| bad(ID, e))?;
Ok(json!({"id": id, "page": page}))
}
fn auto_layout(s: &mut Session, _: &Value) -> Result<Value> {
s.print.layout.auto_layout().map_err(|e| bad("print.layout.autoLayout", e))?;
Ok(json!({"pages": s.print.layout.pages, "cells": s.print.layout.cells}))
}
fn new_page(s: &mut Session, _: &Value) -> Result<Value> {
let page = s.print.layout.new_page().map_err(|e| bad("print.layout.newPage", e))?;
Ok(json!({"page": page, "pages": s.print.layout.pages, "max": MAX_PAGES}))
}
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
s.print.layout.clear();
Ok(json!({"pages": 1, "cells": 0}))
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.exportPdf";
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(ID, "missing path"))?;
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "path" | "ids" | "id"))) {
return Err(bad(ID, format!("unknown parameter `{k}` (one of path, ids; change the print with print.set)")));
}
if p.get("ids").is_some_and(|v| v.as_array().is_none_or(|a| a.iter().any(|v| v.as_u64().is_none()))) {
return Err(bad(ID, "ids must be an array of photo ids"));
}
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
return Err(bad(ID, "id must be a photo id"));
}
s.check_write_target(path).map_err(|e| bad(ID, e))?;
let ids = s.targets(p)?;
let settings = s.print.clone();
// a printer profile that can't be used fails the print before anything renders
crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?;
let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string();
let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?;
let total = work.len() as u64;
let row = s.activity.start("export", "Printing to PDF", crate::activity::Cancel::No);
row.progress(0, total);
let pages = work
.run(&mut |done, name| {
row.progress(done as u64, total);
row.detail(name);
true
})
.map_err(|e| bad(ID, e))?;
let bytes = crate::print::output::write(&pages, &settings.job, &title).map_err(|e| bad(ID, e))?;
s.check_write_target(path).map_err(|e| bad(ID, e))?;
crate::export::write_file(path, &bytes).map_err(|e| bad(ID, e))?;
Ok(json!({"path": path, "pages": pages.len(), "photos": ids.len(), "bytes": bytes.len()}))
}
// ---- templates ------------------------------------------------------------------------------
fn template_list(s: &Session) -> Value {
json!({"templates": s.print_templates.iter().map(|t| json!({"name": t.name, "style": t.settings.pointer("/layout/style"), "settings": t.settings})).collect::<Vec<_>>()})
}
fn template_name(p: &Value, id: &str) -> Result<String> {
clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e))
}
fn template_save(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.template.save";
let name = template_name(p, ID)?;
let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?;
match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = t,
None => {
if s.print_templates.len() >= MAX_TEMPLATES {
return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first")));
}
s.print_templates.push(t);
}
}
s.save_prefs()?;
Ok(template_list(s))
}
fn template_apply(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.template.apply";
let name = template_name(p, ID)?;
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)")))?;
s.print = t.settings(&s.print.guides).map_err(|e| bad(ID, e))?;
reply(s)
}
fn template_delete(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.template.delete";
let name = template_name(p, ID)?;
let before = s.print_templates.len();
s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name));
if s.print_templates.len() == before {
return Err(bad(ID, format!("no print template `{name}`")));
}
s.save_prefs()?;
Ok(template_list(s))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "print.get", "Print Settings", [], None,
"{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",
always, set),
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
cmd!(query "print.cell.add", "Add Print Cell", [], None,
"{width, height, x?, y?, page?, photo?} inches; picture/custom package → {id, cell, pages}; the cell goes to the first free spot unless x and y are given",
always, cell_add),
cmd!(query "print.cell.move", "Move Print Cell", [], None, "{id, x, y, snap?: bool, threshold?: inches} → {id, rect}; snap uses the guides' snap mode", always, cell_move),
cmd!(query "print.cell.resize", "Resize Print Cell", [], None,
"{id, width?, height?, x?, y?, snap?: bool, threshold?} → {id, rect}", always, cell_resize),
cmd!(query "print.cell.delete", "Delete Print Cell", [], None, "{id}", always, cell_delete),
cmd!(query "print.cell.stack", "Arrange Print Cell", [], None, "{id, order: toBack|backward|forward|toFront} → {order: cell ids back to front}", always, cell_stack),
cmd!(query "print.cell.photo", "Set Print Cell Photo", [], None, "{id, photo: photo id | null} (custom package: the photo printed in the cell)", always, cell_photo),
cmd!(query "print.cell.page", "Move Print Cell to Page", [], None, "{id, page: zero-based}", always, cell_page),
cmd!(query "print.layout.autoLayout", "Auto Layout", [], None, "{} — packs the package's cells onto as few pages as possible → {pages, cells}", always, auto_layout),
cmd!(query "print.layout.newPage", "New Print Page", [], None, "{} → {page, pages}", always, new_page),
cmd!(query "print.layout.clear", "Clear Print Layout", [], None, "{} — removes every cell and extra page", always, clear),
cmd!(query "print.exportPdf", "Print to PDF", [], None,
"{path, ids?: photo ids (default selection)} → {path, pages, photos, bytes}; prints the current print.get settings; replaces an existing output, never an original",
always, export_pdf),
cmd!(query "print.template.list", "Print Templates", [], None, "{} → {templates: [{name, style, settings}]}", always, |s, _| Ok(template_list(s))),
cmd!(query "print.template.save", "Save Print Template", [], None,
"{name} — saves the layout, image settings, page and job (not the cells' photos or the guides); an existing name (any case) is replaced → print.template.list",
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),
]
}
+2 -2
View File
@@ -12,7 +12,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
let mut merged = Vec::new();
let mut backups = Vec::new();
let mut failed = Vec::new();
let ids = s.targets(p);
let ids = s.targets(p)?;
let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids);
for id in ids {
match s.save_sidecar_with(id, &owners) {
@@ -37,7 +37,7 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
let mut read = Vec::new();
let mut failed = Vec::new();
let mut warnings = Vec::new();
for id in s.targets(p) {
for id in s.targets(p)? {
match s.read_sidecar_op_with_warnings(id) {
Ok(Some((op, from, mut photo_warnings))) => {
ops.push(op);
+1 -1
View File
@@ -83,7 +83,7 @@ pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> {
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
return Err("id must be a photo id".into());
}
let ids = session.targets(p);
let ids = session.targets(p).map_err(|e| e.to_string())?;
if ids.is_empty() || ids.len() > 1000 {
return Err("select between 1 and 1000 photos".into());
}
+114 -12
View File
@@ -185,6 +185,17 @@ impl DestinationMode {
}
}
impl Conflict {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"unique" => Some(Self::Unique),
"overwrite" => Some(Self::Overwrite),
"skip" => Some(Self::Skip),
_ => None,
}
}
}
/// A named set of `app.export` params.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExportPreset {
@@ -214,6 +225,18 @@ pub enum SharpenFor {
Glossy,
}
impl SharpenFor {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"screen" => Some(Self::Screen),
"matte" => Some(Self::Matte),
"glossy" => Some(Self::Glossy),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SharpenAmount {
@@ -223,6 +246,17 @@ pub enum SharpenAmount {
High,
}
impl SharpenAmount {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"low" => Some(Self::Low),
"standard" => Some(Self::Standard),
"high" => Some(Self::High),
_ => None,
}
}
}
/// Where a watermark sits.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -537,6 +571,18 @@ pub enum MetadataPolicy {
None,
}
impl MetadataPolicy {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"all" => Some(Self::All),
"allexceptcamera" => Some(Self::AllExceptCamera),
"copyright" => Some(Self::Copyright),
"none" => Some(Self::None),
_ => None,
}
}
}
/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or
/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -814,6 +860,10 @@ impl ExportOptions {
let s = string(cmd, k, v)?;
if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) }
};
// a choice `from_json` reads with the same `parse`, so what passes here is what gets exported
let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> {
if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) }
};
for (k, v) in obj {
if v.is_null() {
continue;
@@ -869,10 +919,10 @@ impl ExportOptions {
"naming" | "subfolder" | "path" | "dir" | "preset" => {
string(cmd, k, v)?;
}
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
"sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?,
"sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?,
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
"conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?,
"destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?,
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
@@ -880,7 +930,7 @@ impl ExportOptions {
serde_json::from_value::<DngCompression>(v.clone())
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?;
}
"metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?,
"metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?,
"colorSpace" => {
if OutputSpace::parse(string(cmd, k, v)?).is_none() {
return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020")));
@@ -982,13 +1032,13 @@ impl ExportOptions {
resize: Self::resize_from_json(p),
ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16),
limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32),
sharpen: enm(p, "sharpen").unwrap_or(d.sharpen),
sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount),
sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen),
sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount),
naming: s("naming").map_or(d.naming, str::to_string),
start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32),
destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode),
subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")),
conflict: enm(p, "conflict").unwrap_or(d.conflict),
conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict),
tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() {
Some("none") => TiffCompression::None,
Some("lzw") => TiffCompression::Lzw,
@@ -996,7 +1046,7 @@ impl ExportOptions {
_ => d.tiff_compression,
},
dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression),
metadata: enm(p, "metadata").unwrap_or(d.metadata),
metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata),
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
watermark: match p.get("watermark") {
Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }),
@@ -2457,7 +2507,7 @@ mod tests {
fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() {
use ab_glyph::Font;
let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else {
eprintln!("skipped: built without BIZ UDMincho from craft-fonts");
lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts");
return;
};
let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap();
@@ -2529,7 +2579,7 @@ mod tests {
compare(composed, decomposed, crate::fonts::CRAFT_FONTS);
}
} else {
eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts");
lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts");
}
}
@@ -2553,7 +2603,7 @@ mod tests {
use ab_glyph::Font;
let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS);
if fonts.len() < 2 {
eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face");
lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face");
return;
}
for c in "日本語の文字".chars() {
@@ -3182,6 +3232,58 @@ mod tests {
assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o);
}
/// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the
/// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and
/// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and
/// `conflict` had the same gap.
#[test]
fn accepted_choices_are_the_ones_exported() {
use serde_json::json;
let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string());
let metadata = [
("all", MetadataPolicy::All),
("allExceptCamera", MetadataPolicy::AllExceptCamera),
("copyright", MetadataPolicy::Copyright),
("none", MetadataPolicy::None),
];
for (name, want) in metadata {
for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] {
assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}");
}
}
for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)]
{
assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want);
}
for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] {
assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want);
}
for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] {
assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want);
}
// an explicit value nothing reads as is an error, never the permissive default
for v in ["", " none", "nothing", "all-except-camera"] {
let e = read("metadata", v).unwrap_err();
assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}");
}
assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high"));
assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip"));
// absent (or null) keeps the defaults
let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap();
assert_eq!(
(d.metadata, d.sharpen, d.sharpen_amount, d.conflict),
(MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique)
);
// `removeLocation` stays independent of the policy's spelling
let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap();
assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true));
// stored settings (lenient `from_json`) read the same spellings
assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None);
// and `to_json` still writes what the MCP schema lists
let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json();
assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip")));
}
/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in
/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently.
#[test]
@@ -3264,7 +3366,7 @@ mod tests {
#[test]
fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() {
if !lightcraft_gpu::available() {
eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason());
lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default());
return;
}
let mut s = crate::Session::with_demo();
+75 -25
View File
@@ -159,10 +159,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
}
Err(Err(e)) => return Err(e),
};
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
if w <= 1 || h <= 1 {
(w, h) = (raw.active_area.width as u32, raw.active_area.height as u32);
}
let (w, h) = raw.developed_size();
let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32);
if raw.orientation.swaps_axes() {
std::mem::swap(&mut w, &mut h);
}
@@ -267,19 +265,43 @@ impl CameraColourModel<'_> {
/// White balance, matrix, tables and baseline exposure, in place.
pub fn apply(&self, img: &mut Rgb32f) {
let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain);
img.map_in_place(|p| {
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
let rgb = [
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
];
let rgb = match self.tables {
Some(tables) => tables.apply(rgb, gain),
None => rgb.map(|v| v * gain),
};
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
});
if let (None, Some(hue_sat)) = (self.tables, self.hue_sat) {
// a fitted camera look: `pixel`, `LANES` pixels at a time so the table step vectorises (same bits)
const LANES: usize = 16;
lightcraft_raster::par_rows(&mut img.data, img.width, |_, row| {
for chunk in row.chunks_mut(LANES) {
let mut rgb = [[0.0f32; LANES]; 3];
for (l, p) in chunk.iter().enumerate() {
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
for (i, rgb) in rgb.iter_mut().enumerate() {
rgb[l] = (m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) * gain;
}
}
let out = hue_sat.apply_lanes(rgb);
for (l, p) in chunk.iter_mut().enumerate() {
*p = [out[0][l], out[1][l], out[2][l]].map(|v| v.max(0.0));
}
}
});
return;
}
img.map_in_place(|p| self.pixel(&m, p));
}
/// [`Self::apply`] to one pixel; `m` is the matrix as `f32`.
fn pixel(&self, m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] {
let (wb, gain) = (self.wb, self.gain);
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
let rgb = [
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
];
let rgb = match self.tables {
Some(tables) => tables.apply(rgb, gain),
None => rgb.map(|v| v * gain),
};
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
}
}
@@ -408,8 +430,10 @@ fn load_bytes_now(
// the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera)
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
let ((xy, t, camera_look), mattes) =
rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref()), || dng_mattes(&bytes, &info));
let ((xy, t, camera_look), mattes) = rayon::join(
|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref(), lens_geometry.as_ref()),
|| dng_mattes(&bytes, &info),
);
drop(bytes);
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
// larger levels (exports, 1:1) demosaic the whole sensor.
@@ -479,10 +503,11 @@ fn load_bytes_now(
// on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the
// picture is adapted from the as-shot white as every fitted look is (so settings from before that scale
// existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`).
let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| {
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
});
let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng))
.then(|| {
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
});
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
let local_tone = local_tone(&raw);
let (temp, tint) = scale.as_shot(xy);
@@ -1643,7 +1668,7 @@ pub(crate) mod tests {
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else {
eprintln!("skip: {} absent", dir.display());
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
return;
};
assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply");
@@ -1810,6 +1835,31 @@ pub(crate) mod tests {
lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]])
}
/// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the
/// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included.
#[test]
fn camera_look_in_lanes_matches_pixel_by_pixel() {
let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect();
let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true };
let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap();
let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) };
let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN];
let img = Rgb32f::from_fn(37, 5, |x, y| {
let k = y * 37 + x;
if k % 5 == 0 {
[special[k % 10], special[k / 10 % 10], special[k / 100 % 10]]
} else {
[(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05]
}
});
let mut lanes = img.clone();
colour.apply(&mut lanes);
let m = colour.matrix.to_f32();
for (p, got) in img.data.iter().zip(&lanes.data) {
assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}");
}
}
#[test]
fn clipped_white_renders_neutral_through_the_colour_model() {
use lightcraft_color::{LUMA_2020, Mat3};
@@ -1994,7 +2044,7 @@ pub(crate) mod tests {
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else {
eprintln!("skip: {} absent", dir.display());
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
return;
};
let (img, _) = load_bytes(&bytes, 1600).unwrap();
+1 -2
View File
@@ -64,8 +64,7 @@ fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) -
pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> {
crate::files::embedded_lens(raw).or_else(|| {
let db = database()?;
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) };
let (w, h) = raw.developed_size();
lookup(db, &meta(), w as f64, h as f64, raw.orientation)
})
}
+40 -9
View File
@@ -59,6 +59,10 @@ mod pdf;
pub mod preset_import;
pub mod preset_luminar;
pub mod presets;
pub mod print;
pub mod print_color;
pub mod print_pdf;
pub mod profile_refs;
pub mod proof;
pub mod rename;
pub mod segment;
@@ -291,6 +295,11 @@ pub struct Session {
pub last_export: Option<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
/// with the session, so the app, CLI, control channel and MCP all see the same print.
pub print: print::template::PrintSettings,
/// Saved print templates (`print.template.*`), persisted in prefs.json.
pub print_templates: Vec<print::template::PrintTemplate>,
/// Metadata presets (`metadata.*`), persisted in prefs.json.
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
@@ -444,6 +453,8 @@ impl Session {
xmp: sidecar::XmpPrefs::default(),
last_export: None,
export_presets: Vec::new(),
print: Default::default(),
print_templates: Vec::new(),
metadata_presets: Vec::new(),
filter_presets: Vec::new(),
curve_presets: Vec::new(),
@@ -500,7 +511,11 @@ impl Session {
}
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)).map_err(|e| match e {
// a shared parameter check ([`Session::targets`]) names the command it ran for
EngineError::BadParams { cmd, msg } if cmd.is_empty() => EngineError::BadParams { cmd: id.to_string(), msg },
e => e,
})
}
/// Run `f` as command `id` with what [`Session::execute`] does around every command: the
@@ -1027,8 +1042,12 @@ impl Session {
&& !al.is_smart()
&& self.filter == Filter::default()
{
let order = al.photos.clone();
visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
// each photo's place looked up, not searched for (a big album sorted in O(n log n))
let mut place: std::collections::HashMap<PhotoId, usize> = std::collections::HashMap::with_capacity(al.photos.len());
for (i, id) in al.photos.iter().enumerate() {
place.entry(*id).or_insert(i);
}
visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX));
if !self.sort.ascending {
visible.reverse();
}
@@ -1167,14 +1186,24 @@ impl Session {
(self.visible_gen, self.visible.clone())
}
/// Targets of photo commands: explicit `ids`/`id` param, else the selection.
pub fn targets(&self, p: &Value) -> Vec<PhotoId> {
if let Some(a) = p.get("ids").and_then(Value::as_array) {
return a.iter().filter_map(Value::as_u64).map(PhotoId).collect();
/// Targets of photo commands: the explicit `ids` (photo ids) or `id` (a photo id) param, else [`Self::selected`].
/// An `ids` or `id` that is there but isn't photo ids is an error, never the selection or part of the list
/// (issue #1506): a mistyped target mustn't turn a command meant for one photo into one for every selected photo.
pub fn targets(&self, p: &Value) -> Result<Vec<PhotoId>> {
let invalid = |what: &str| EngineError::BadParams { cmd: String::new(), msg: format!("`{what}` must be photo ids (whole numbers)") };
match p.get("ids") {
None | Some(Value::Null) => {}
Some(Value::Array(a)) => return a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| invalid("ids"))).collect(),
Some(_) => return Err(invalid("ids")),
}
if let Some(id) = p.get("id").and_then(Value::as_u64) {
return vec![PhotoId(id)];
match p.get("id") {
None | Some(Value::Null) => Ok(self.selected()),
Some(v) => v.as_u64().map(|id| vec![PhotoId(id)]).ok_or_else(|| invalid("id")),
}
}
/// The photos commands act on when a call names none: the selection, else the active photo.
pub fn selected(&self) -> Vec<PhotoId> {
if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() }
}
}
@@ -1254,6 +1283,8 @@ mod tests_prefs;
#[cfg(test)]
mod tests_presets_scale;
#[cfg(test)]
mod tests_print;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_recheck;
+4
View File
@@ -209,6 +209,8 @@ struct PrefsFile {
last_export: Option<serde_json::Value>,
/// The user's export presets.
export_presets: Vec<crate::export::ExportPreset>,
/// Print templates.
print_templates: Vec<crate::print::template::PrintTemplate>,
/// Metadata presets.
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
/// Filter presets.
@@ -491,6 +493,7 @@ impl Session {
self.xmp = prefs.xmp;
self.last_export = prefs.last_export;
self.export_presets = prefs.export_presets;
self.print_templates = prefs.print_templates;
self.metadata_presets = prefs.metadata_presets;
self.filter_presets = prefs.filter_presets;
self.curve_presets = prefs.curve_presets;
@@ -744,6 +747,7 @@ impl Session {
xmp: self.xmp,
last_export: self.last_export.clone(),
export_presets: self.export_presets.clone(),
print_templates: self.print_templates.clone(),
metadata_presets: self.metadata_presets.clone(),
filter_presets: self.filter_presets.clone(),
curve_presets: self.curve_presets.clone(),
+1 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 63;
pub const RENDER_CACHE_VERSION: u64 = 64;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
+1
View File
@@ -191,6 +191,7 @@ impl MergeJob {
metadata: r.metadata,
baseline_exposure: r.baseline_exposure,
lens: Vec::new(),
wb_multipliers: r.wb_multipliers,
};
f.orient();
merged.push(f);
+31
View File
@@ -395,6 +395,20 @@ pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_ma
let mut values = crate::crs_masks::Values::new();
for (k, v) in fields {
let key = format!("crs:{k}");
// the profile a preset names: its fields as XMP's struct fields (`crs:Look/crs:Name`…)
if k == "Look"
&& let Lua::Table(_, look) = v
{
for (f, fv) in look {
let val = match fv {
Lua::Num(n) => format!("{n}"),
Lua::Str(s) => delocalize(s),
_ => continue,
};
props.insert(format!("crs:Look/crs:{f}"), vec![val]);
}
continue;
}
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
values.insert(key.clone(), crate::crs_masks::from_lua(v));
}
@@ -497,6 +511,12 @@ pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: Stri
o.remove("wb");
}
}
// the profile it names, resolved against the user's imported profiles by `preset.import`
if let Some(look) = crate::profile_refs::look_ref(props)
&& let Some(o) = settings.as_object_mut()
{
o.insert("profile".into(), serde_json::json!({"look": look}));
}
if settings.as_object().is_none_or(Map::is_empty) {
return None;
}
@@ -684,6 +704,17 @@ text]], z = ZSTR "loc" }"#,
assert_eq!(d.light.exposure, 0.35);
}
#[test]
fn lrtemplate_keeps_the_profile_it_names() {
let t = r#"s = { title = "Film", value = { settings = { Exposure2012 = 0.2,
Look = { Name = "Portra 400", Amount = 0.5, UUID = "ABCD", Group = "$$$/Pack=Film Pack", Parameters = { Version = "14.0" } } } } }"#;
let v = read_presets("Film.lrtemplate", t.as_bytes(), None).unwrap();
assert_eq!(
v[0].preset.settings["profile"],
serde_json::json!({"look": {"name": "Portra 400", "amount": 50.0, "group": "Film Pack", "uuid": "abcd"}})
);
}
#[test]
fn old_process_version_fields_are_approximated() {
let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }";
+856
View File
@@ -0,0 +1,856 @@
//! 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.
//!
//! 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
//! background thread with progress and cancellation.
//!
//! Photos are rendered with the normal export pipeline into Rec. 2020 at 16 bits, at exactly the
//! pixel size the cell needs at the job's resolution; colours given in sRGB (background, border,
//! stroke) are converted to the same encoding.
use super::layout::{Layout, Rect, Style};
use super::template::PrintSettings;
use super::{MAX_JOB_PAGES, MAX_JOB_PIXELS, MAX_PAGE_PIXELS, PageRaster};
use crate::Session;
use crate::export::{ExportOptions, output_size};
use crate::media::RenderJob;
use lightcraft_catalog::PhotoId;
use lightcraft_color::transfer::srgb_to_linear;
use lightcraft_color::{REC2020, SRGB};
use lightcraft_pipeline::{DeepSamples, OutputDepth, OutputSpace};
use serde::{Deserialize, Serialize};
/// Most photos one print job takes.
pub const MAX_PHOTOS: usize = 1000;
/// Widest Photo Border, inches.
pub const MAX_BORDER_IN: f64 = 2.0;
/// Widest Inner Stroke, points.
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;
/// Lightroom's Image Settings panel.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct ImageSettings {
/// Fill each cell with the photo and crop what overflows (centred); off: fit the whole photo.
pub zoom_to_fill: bool,
/// Turn a photo a quarter when that makes it print larger (fit) or crop less (fill).
pub rotate_to_fit: bool,
/// A border around the photo inside its cell; Picture Package and Custom Package only.
pub border_enabled: bool,
/// Border width, inches.
pub border_width: f64,
/// Border colour, sRGB.
pub border_color: [u8; 3],
/// A stroke drawn inside the photo's edge, every layout style.
pub stroke_enabled: bool,
/// Stroke width, points.
pub stroke_width_pt: f64,
/// Stroke colour, sRGB.
pub stroke_color: [u8; 3],
}
impl Default for ImageSettings {
fn default() -> Self {
Self {
zoom_to_fill: false,
rotate_to_fit: false,
border_enabled: false,
border_width: 0.25,
border_color: [255; 3],
stroke_enabled: false,
stroke_width_pt: 1.0,
stroke_color: [0; 3],
}
}
}
impl ImageSettings {
pub fn validate(&self) -> Result<(), String> {
let ok = |v: f64, max: f64| v.is_finite() && (0.0..=max).contains(&v);
if !ok(self.border_width, MAX_BORDER_IN) {
return Err(format!("border width must be 0 to {MAX_BORDER_IN} in"));
}
if !ok(self.stroke_width_pt, MAX_STROKE_PT) {
return Err(format!("stroke width must be 0 to {MAX_STROKE_PT} pt"));
}
Ok(())
}
}
/// Lightroom's Page panel (the parts that are not margins or overlays).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct PageStyle {
/// Page background colour, sRGB.
pub background: [u8; 3],
}
impl Default for PageStyle {
fn default() -> Self {
Self { background: [255; 3] }
}
}
/// An sRGB colour as 16-bit Rec. 2020 samples (the encoding of [`PageRaster`]).
pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
let lin = SRGB.to_space(&REC2020).apply_f32(c.map(|v| srgb_to_linear(f32::from(v) / 255.0)));
let trc = OutputSpace::Rec2020.trc();
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
}
// ---- placement ------------------------------------------------------------------------------
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placement {
/// Turned a quarter (clockwise).
pub rotated: bool,
/// Scaled size after turning: at most the area when fitting, at least the area when filling.
pub w: usize,
pub h: usize,
}
impl Placement {
/// The scaled size before turning (the size to render at).
pub fn unturned(&self) -> (usize, usize) {
if self.rotated { (self.h, self.w) } else { (self.w, self.h) }
}
}
/// Decides turning and scale. `None` for an empty area or a photo without size.
pub fn place(iw: f64, ih: f64, aw: usize, ah: usize, fill: bool, rotate: bool) -> Option<Placement> {
if !(iw.is_finite() && ih.is_finite() && iw >= 1.0 && ih >= 1.0) || aw == 0 || ah == 0 {
return None;
}
let (af, bf) = (aw as f64, ah as f64);
let scale = |w: f64, h: f64| if fill { (af / w).max(bf / h) } else { (af / w).min(bf / h) };
let (s0, s1) = (scale(iw, ih), scale(ih, iw));
let rotated = rotate && if fill { s1 < s0 * (1.0 - 1e-9) } else { s1 > s0 * (1.0 + 1e-9) };
let (w, h) = if rotated { (ih * s1, iw * s1) } else { (iw * s0, ih * s0) };
let (w, h) = if fill {
(((w - 1e-6).ceil() as usize).max(aw), ((h - 1e-6).ceil() as usize).max(ah))
} else {
((w.round() as usize).clamp(1, aw), (h.round() as usize).clamp(1, ah))
};
Some(Placement { rotated, w, h })
}
/// A rectangle in page pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PxRect {
pub x: usize,
pub y: usize,
pub w: usize,
pub h: usize,
}
/// The pixel rectangle of an inch rectangle on a `pw` x `ph` page at `ppi`, clipped to the page.
pub fn px_rect(r: &Rect, ppi: f64, pw: usize, ph: usize) -> Option<PxRect> {
if !r.is_finite() {
return None;
}
let edge = |v: f64, max: usize| (v * ppi).round().clamp(0.0, max as f64) as usize;
let (x0, y0, x1, y1) = (edge(r.x, pw), edge(r.y, ph), edge(r.right(), pw), edge(r.bottom(), ph));
(x1 > x0 && y1 > y0).then_some(PxRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 })
}
/// The cells of every page with the photo each prints, by layout style: Single Image and Contact
/// Sheet lay `photos` out in sequence over as many pages as needed; Picture Package prints the
/// first photo in every cell; Custom Package prints each cell's own photo (else the first).
pub fn plan_cells(layout: &Layout, photos: &[PhotoId]) -> Result<Vec<Vec<(Rect, PhotoId)>>, String> {
let first = photos.first().copied().ok_or("select at least one photo to print")?;
if photos.len() > MAX_PHOTOS {
return Err(format!("a print takes at most {MAX_PHOTOS} photos"));
}
match layout.style {
Style::SingleImage | Style::ContactSheet => {
let grid = layout.grid_rects()?;
let per_page = grid.len().max(1);
let pages = photos.len().div_ceil(per_page);
if pages > MAX_JOB_PAGES {
return Err(format!("a print is limited to {MAX_JOB_PAGES} pages; use a bigger grid or fewer photos"));
}
Ok(photos.chunks(per_page).map(|chunk| chunk.iter().zip(&grid).map(|(id, r)| (*r, *id)).collect()).collect())
}
Style::PicturePackage | Style::CustomPackage => {
let pages = layout.pages.max(1) as usize;
if pages > MAX_JOB_PAGES {
return Err(format!("a print is limited to {MAX_JOB_PAGES} pages"));
}
if layout.cells.is_empty() {
return Err("the layout has no cells; add some (print.cell.add) or use Auto Layout".into());
}
let mut out: Vec<Vec<(Rect, PhotoId)>> = vec![Vec::new(); pages];
for c in &layout.cells {
let photo = match layout.style {
Style::CustomPackage => c.photo.map_or(first, PhotoId),
_ => first,
};
out.get_mut(c.page as usize).ok_or("a cell is on a page the layout does not have")?.push((c.rect, photo));
}
Ok(out)
}
}
}
// ---- pixels ----------------------------------------------------------------------------------
/// 16-bit interleaved RGB.
#[derive(Clone, Debug, PartialEq)]
pub struct Image16 {
pub w: usize,
pub h: usize,
pub rgb: Vec<u16>,
}
impl Image16 {
pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result<Self, String> {
let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?;
let mut rgb = Vec::new();
rgb.try_reserve_exact(n).map_err(|_| "not enough memory for the page")?;
for _ in 0..w * h {
rgb.extend_from_slice(&c);
}
Ok(Self { w, h, rgb })
}
fn px(&self, x: usize, y: usize) -> Option<[u16; 3]> {
let i = y.checked_mul(self.w)?.checked_add(x)?.checked_mul(3)?;
let s = self.rgb.get(i..i.checked_add(3)?)?;
Some([*s.first()?, *s.get(1)?, *s.get(2)?])
}
/// Fills a rectangle (clipped to the image).
pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, c: [u16; 3]) {
let x1 = x.saturating_add(w).min(self.w);
for row in y..y.saturating_add(h).min(self.h) {
let Some(line) = self.rgb.get_mut((row * self.w + x.min(x1)) * 3..(row * self.w + x1) * 3) else { continue };
for px in line.as_chunks_mut::<3>().0 {
*px = c;
}
}
}
/// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images.
pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) {
let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx));
let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy));
for row in 0..h {
let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3);
let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3);
if let (Some(from), Some(to)) = (from, to) {
to.copy_from_slice(from);
}
}
}
/// A quarter turn clockwise.
pub fn rotated_cw(&self) -> Result<Image16, String> {
let mut out = Image16::filled(self.h, self.w, [0; 3])?;
for y in 0..self.h {
for x in 0..self.w {
let Some(p) = self.px(x, y) else { continue };
// (x, y) lands at (h - 1 - y, x).
let i = (x * out.w + (self.h - 1 - y)) * 3;
if let Some(d) = out.rgb.get_mut(i..i + 3) {
d.copy_from_slice(&p);
}
}
}
Ok(out)
}
/// Scales to `nw` x `nh`: area-weighted when shrinking an axis, linear when growing it.
pub fn resized(&self, nw: usize, nh: usize) -> Result<Image16, String> {
if nw == 0 || nh == 0 || self.w == 0 || self.h == 0 {
return Err("empty image".into());
}
if (nw, nh) == (self.w, self.h) {
return Ok(self.clone());
}
let wx = axis_weights(self.w, nw);
let wy = axis_weights(self.h, nh);
// Horizontal pass into 16-bit rows, then the vertical pass.
let mut mid = Image16::filled(nw, self.h, [0; 3])?;
for y in 0..self.h {
let Some(src) = self.rgb.get(y * self.w * 3..(y + 1) * self.w * 3) else { continue };
let Some(dst) = mid.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue };
for (x, taps) in wx.iter().enumerate() {
for c in 0..3 {
let v: f32 = taps.iter().map(|&(i, k)| src.get(i * 3 + c).map_or(0.0, |s| f32::from(*s) * k)).sum();
if let Some(d) = dst.get_mut(x * 3 + c) {
*d = v.round().clamp(0.0, 65535.0) as u16;
}
}
}
}
let mut out = Image16::filled(nw, nh, [0; 3])?;
for (y, taps) in wy.iter().enumerate() {
let Some(dst) = out.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue };
for (i, d) in dst.iter_mut().enumerate() {
let v: f32 = taps.iter().map(|&(row, k)| mid.rgb.get(row * nw * 3 + i).map_or(0.0, |s| f32::from(*s) * k)).sum();
*d = v.round().clamp(0.0, 65535.0) as u16;
}
}
Ok(out)
}
}
/// For each of `new` output samples, the `(source index, weight)` taps that make it (weights sum to 1).
fn axis_weights(old: usize, new: usize) -> Vec<Vec<(usize, f32)>> {
let (o, n) = (old as f64, new as f64);
(0..new)
.map(|i| {
if new < old {
let step = o / n;
let (a, b) = (i as f64 * step, (i as f64 + 1.0) * step);
let first = a.floor() as usize;
let last = (b.ceil() as usize).min(old);
(first..last)
.filter_map(|j| {
let overlap = (b.min(j as f64 + 1.0) - a.max(j as f64)).max(0.0);
(overlap > 0.0).then_some((j, (overlap / step) as f32))
})
.collect()
} else {
let pos = (i as f64 + 0.5) * o / n - 0.5;
let j0 = pos.floor();
let f = (pos - j0) as f32;
let at = |j: f64| j.clamp(0.0, o - 1.0) as usize;
vec![(at(j0), 1.0 - f), (at(j0 + 1.0), f)]
}
})
.collect()
}
/// Everything about a cell's look that does not depend on the photo.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CellStyle {
pub fill: bool,
pub rotate: bool,
pub border_px: usize,
pub border: [u16; 3],
pub stroke_px: usize,
pub stroke: [u16; 3],
}
impl CellStyle {
/// The style for `settings` at `ppi`; the border applies to the package styles only.
pub fn of(settings: &PrintSettings, ppi: f64) -> Self {
let img = &settings.image;
let border_px = if img.border_enabled && settings.layout.style.is_package() { (img.border_width * ppi).round() as usize } else { 0 };
let stroke_px =
if img.stroke_enabled && img.stroke_width_pt > 0.0 { ((img.stroke_width_pt / 72.0 * ppi).round() as usize).max(1) } else { 0 };
Self {
fill: img.zoom_to_fill,
rotate: img.rotate_to_fit,
border_px,
border: rec2020_from_srgb8(img.border_color),
stroke_px,
stroke: rec2020_from_srgb8(img.stroke_color),
}
}
}
/// 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> {
// 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);
let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border);
let p = place(photo.w as f64, photo.h as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?;
let (uw, uh) = p.unturned();
let mut img = photo.resized(uw, uh)?;
if p.rotated {
img = img.rotated_cw()?;
}
// Where the photo lands: centred in the area, cropped to it when it overflows.
let (sx, sy) = (img.w.saturating_sub(aw) / 2, img.h.saturating_sub(ah) / 2);
let (w, h) = (img.w.min(aw), img.h.min(ah));
let (dx, dy) = (ax + (aw - w) / 2, ay + (ah - h) / 2);
if border > 0 {
page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border);
}
page.blit(&img, sx, sy, dx, dy, w, h);
let s = style.stroke_px.min(w.min(h) / 2);
if s > 0 {
page.fill_rect(dx, dy, w, s, style.stroke);
page.fill_rect(dx, dy + h - s, w, s, style.stroke);
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(())
}
// ---- the job ---------------------------------------------------------------------------------
struct Item {
page: usize,
name: String,
cell: PxRect,
key: (u64, usize, usize),
job: RenderJob,
}
/// A print job set up from the session: one render per cell, ready to run on another thread.
pub struct Prepared {
pages: Vec<(usize, usize)>,
items: Vec<Item>,
ppi: f32,
background: [u16; 3],
style: CellStyle,
/// Print sharpening, applied to each photo once it's rendered at its print size.
color: crate::print_color::PrintColorSettings,
}
impl Prepared {
/// Renders (progress steps) the job will run.
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Pages the job will produce.
pub fn pages(&self) -> usize {
self.pages.len()
}
/// Renders every photo and composes the pages. `progress(done, name)` is called before each
/// render with the photo's file name; returning `false` cancels (no partial result).
pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Vec<PageRaster>, String> {
let mut cache: Vec<((u64, usize, usize), Image16)> = Vec::new();
let mut done = 0usize;
let mut items = self.items.into_iter().peekable();
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)?;
while let Some(item) = items.next_if(|i| i.page == index) {
if !progress(done, &item.name) {
return Err("Print cancelled".into());
}
done += 1;
let at = cache.iter().position(|(k, _)| *k == item.key);
let at = match at {
Some(at) => at,
None => {
let img = render(item.job, &self.color).map_err(|e| format!("{}: {e}", item.name))?;
if cache.len() >= RENDER_CACHE {
cache.remove(0);
}
cache.push((item.key, img));
cache.len() - 1
}
};
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))?;
}
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
}
if !progress(done, "") {
return Err("Print cancelled".into());
}
Ok(out)
}
}
fn render(job: RenderJob, color: &crate::print_color::PrintColorSettings) -> Result<Image16, String> {
let rendered = job.run().rendered?;
let mut deep = rendered.deep.ok_or("the render has no 16-bit samples")?;
// print sharpening for the paper, at the size the photo prints
color.sharpen_photo(&mut deep);
let DeepSamples::U16(rgb) = deep.samples else { return Err("the render is not 16-bit".into()) };
if deep.space != OutputSpace::Rec2020 || rgb.len() != deep.width.saturating_mul(deep.height).saturating_mul(3) {
return Err("the render is not Rec. 2020 RGB".into());
}
Ok(Image16 { w: deep.width, h: deep.height, rgb })
}
/// Sets a print job up: the cells for `photos` under `settings.layout`, one render job per cell.
pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoId]) -> Result<Prepared, String> {
let mut settings = settings.clone();
settings.validate()?;
let ppi = f64::from(settings.job.effective_ppi());
let (pw_in, ph_in) = settings.layout.page_size()?;
let (pw, ph) = ((pw_in * ppi).round() as usize, (ph_in * ppi).round() as usize);
let page_px = (pw as u64).saturating_mul(ph as u64);
if pw == 0 || ph == 0 || page_px > MAX_PAGE_PIXELS {
return Err(format!("a page of {pw} x {ph} pixels is too large; lower the print resolution"));
}
let plan = plan_cells(&settings.layout, photos)?;
if page_px.saturating_mul(plan.len() as u64) > MAX_JOB_PIXELS {
return Err("the print is too large for memory; lower the print resolution or print fewer pages".into());
}
let style = CellStyle::of(&settings, ppi);
let mut items = Vec::new();
for (page, cells) in plan.iter().enumerate() {
for (rect, id) in cells {
let Some(cell) = px_rect(rect, ppi, pw, ph) else { continue };
let photo = session.catalog.photo(*id).ok_or_else(|| format!("no photo {}", id.0))?;
let name = photo.file_name.clone();
let (nw, nh) = output_size(photo, &ExportOptions::default());
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border);
let p = place(nw as f64, nh as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?;
let (rw, rh) = p.unturned();
let job = session.export_job(*id, rw, rh, OutputSpace::Rec2020, OutputDepth::U16)?;
items.push(Item { page, name, cell, key: (id.0, rw, rh), job });
}
}
Ok(Prepared {
pages: vec![(pw, ph); plan.len()],
items,
ppi: ppi as f32,
background: rec2020_from_srgb8(settings.page.background),
style,
color: settings.job.color.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::print::layout::{GridSpec, Margins};
const RED: [u16; 3] = [60000, 1000, 1000];
const BLUE: [u16; 3] = [1000, 1000, 60000];
const WHITE: [u16; 3] = [65535; 3];
/// A `w` x `h` image whose left half is red and right half blue, with a green top row.
fn halves(w: usize, h: usize) -> Image16 {
let mut img = Image16::filled(w, h, RED).unwrap();
img.fill_rect(w / 2, 0, w - w / 2, h, BLUE);
img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]);
img
}
fn style() -> CellStyle {
CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] }
}
fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] {
img.px(x, y).unwrap()
}
fn page() -> Image16 {
Image16::filled(100, 100, WHITE).unwrap()
}
#[test]
fn placement_fit_fill_and_rotation() {
// 4:3 photo into a 2:1 area.
let fit = place(400.0, 300.0, 200, 100, false, false).unwrap();
assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false));
let fill = place(400.0, 300.0, 200, 100, true, false).unwrap();
assert_eq!((fill.w, fill.h), (200, 150));
// A portrait photo in a landscape area: turning prints it larger.
assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 });
let turned = place(300.0, 400.0, 400, 300, false, true).unwrap();
assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300));
assert_eq!(turned.unturned(), (300, 400));
// Filling a landscape cell with a portrait photo crops less when turned.
let f = place(300.0, 400.0, 400, 300, true, true).unwrap();
assert!(f.rotated && f.w >= 400 && f.h >= 300);
// Matching orientation never turns.
assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated);
assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated);
// Hostile input.
for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] {
assert!(place(bad, 10.0, 10, 10, false, false).is_none());
assert!(place(10.0, bad, 10, 10, true, true).is_none());
}
assert!(place(10.0, 10.0, 0, 10, false, false).is_none());
}
#[test]
fn fit_centres_the_photo() {
let mut p = page();
draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap();
// 40x20 scaled to 80x40, centred vertically at y 30..70.
assert_eq!(at(&p, 10, 50), RED);
assert_eq!(at(&p, 89, 50), BLUE);
assert_eq!(at(&p, 50, 20), WHITE, "above the photo");
assert_eq!(at(&p, 50, 75), WHITE, "below the photo");
assert_eq!(at(&p, 5, 50), WHITE, "outside the cell");
}
#[test]
fn zoom_to_fill_crops_the_centre() {
let mut p = page();
let s = CellStyle { fill: true, ..style() };
draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap();
// The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell.
for y in [10, 50, 89] {
assert_ne!(at(&p, 10, y), WHITE);
assert_ne!(at(&p, 89, y), WHITE);
}
assert_eq!(at(&p, 20, 50), RED);
assert_eq!(at(&p, 80, 50), BLUE);
// Nothing leaks outside the cell.
assert_eq!(at(&p, 9, 50), WHITE);
assert_eq!(at(&p, 90, 50), WHITE);
assert_eq!(at(&p, 50, 9), WHITE);
assert_eq!(at(&p, 50, 90), WHITE);
}
#[test]
fn rotate_to_fit_turns_clockwise() {
// A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell.
let mut p = page();
let cell = PxRect { x: 10, y: 10, w: 80, h: 40 };
draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap();
assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle");
assert_eq!(at(&p, 45, 30), RED);
let mut q = page();
draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap();
// Turned clockwise the left half (red) becomes the top half and the photo spans the cell.
assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12));
assert_eq!(at(&q, 12, 48), BLUE);
assert_eq!(at(&q, 50, 20), RED);
assert_eq!(at(&q, 50, 40), BLUE);
assert_eq!(at(&q, 11, 30), at(&q, 11, 30));
}
#[test]
fn border_frames_the_photo_inside_the_cell() {
let mut p = page();
let green = [100, 60000, 100];
let s = CellStyle { border_px: 5, border: green, fill: true, ..style() };
draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap();
assert_eq!(at(&p, 10, 40), green);
assert_eq!(at(&p, 14, 40), green);
assert_eq!(at(&p, 15, 40), RED);
assert_eq!(at(&p, 69, 40), green);
assert_eq!(at(&p, 64, 40), BLUE);
assert_eq!(at(&p, 40, 10), green);
assert_eq!(at(&p, 40, 69), green);
// A fitted photo gets the border hugging it, not the whole cell.
let mut q = page();
let s = CellStyle { border_px: 5, border: green, ..style() };
draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap();
// The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick.
assert_eq!(at(&q, 50, 23), green);
assert_eq!(at(&q, 50, 22), green);
assert_eq!(at(&q, 50, 21), WHITE);
// A border wider than the cell leaves the photo visible.
let mut r = page();
let s = CellStyle { border_px: 500, border: green, ..style() };
draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap();
assert_ne!(at(&r, 20, 20), WHITE);
}
#[test]
fn stroke_is_drawn_inside_the_photo_edge() {
let mut p = page();
let black = [0, 0, 0];
let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() };
draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap();
for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] {
assert_eq!(at(&p, x, y), black, "({x}, {y})");
}
assert_eq!(at(&p, 23, 40), RED);
assert_eq!(at(&p, 76, 40), BLUE);
assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo");
// Corners are covered exactly once and a huge stroke stays within the photo.
let mut q = page();
let s = CellStyle { stroke_px: 1000, stroke: black, ..style() };
draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap();
assert_eq!(at(&q, 40, 40), black);
assert_eq!(at(&q, 19, 40), WHITE);
assert_eq!(at(&q, 60, 40), WHITE);
}
#[test]
fn colours_are_rec2020_encoded() {
assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]);
assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]);
let grey = rec2020_from_srgb8([128; 3]);
assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3);
// sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero.
let red = rec2020_from_srgb8([255, 0, 0]);
assert!(red[0] < 65535 && red[1] > 0);
}
#[test]
fn resize_preserves_flat_colour_and_edges() {
let img = halves(64, 32);
for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] {
let r = img.resized(w, h).unwrap();
assert_eq!((r.w, r.h), (w, h));
assert_eq!(r.rgb.len(), w * h * 3);
}
let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap();
assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999]));
assert!(img.resized(0, 5).is_err());
let r = img.resized(32, 16).unwrap();
assert_eq!(at(&r, 4, 8), RED);
assert_eq!(at(&r, 28, 8), BLUE);
}
#[test]
fn rotate_and_blit_are_clipped() {
let img = halves(3, 2);
let r = img.rotated_cw().unwrap();
assert_eq!((r.w, r.h), (2, 3));
let mut p = page();
p.blit(&img, 0, 0, 99, 99, 10, 10);
assert_eq!(at(&p, 99, 99), at(&img, 0, 0));
p.blit(&img, 5, 5, 0, 0, 3, 3);
p.fill_rect(95, 95, 50, 50, RED);
assert_eq!(at(&p, 99, 99), RED);
p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED);
}
fn settings(style: Style) -> PrintSettings {
let mut s = PrintSettings::default();
s.layout.style = style;
s.layout.page.paper = "4x6".into();
s.layout.page.margins = Margins::uniform(0.5);
s.job.ppi = 100.0;
s
}
#[test]
fn plans_follow_the_layout_style() {
let ids: Vec<PhotoId> = (1..=5).map(PhotoId).collect();
let mut s = settings(Style::ContactSheet);
s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() };
let plan = plan_cells(&s.layout, &ids).unwrap();
assert_eq!(plan.iter().map(Vec::len).collect::<Vec<_>>(), vec![4, 1]);
assert_eq!(plan[1][0].1, PhotoId(5));
let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap();
assert_eq!(one.len(), 5);
assert!(plan_cells(&s.layout, &[]).is_err());
assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err());
let mut pkg = settings(Style::PicturePackage);
assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet");
pkg.layout.add_cell(1.0, 1.0).unwrap();
pkg.layout.add_cell(2.0, 2.0).unwrap();
let plan = plan_cells(&pkg.layout, &ids).unwrap();
assert_eq!(plan.len(), 1);
assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo");
let mut custom = settings(Style::CustomPackage);
let a = custom.layout.add_cell(1.0, 1.0).unwrap();
custom.layout.add_cell(1.0, 1.0).unwrap();
custom.layout.set_cell_photo(a, Some(3)).unwrap();
let plan = plan_cells(&custom.layout, &ids).unwrap();
assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1)));
let blank_page = {
custom.layout.new_page().unwrap();
plan_cells(&custom.layout, &ids).unwrap()
};
assert_eq!(blank_page.iter().map(Vec::len).collect::<Vec<_>>(), vec![2, 0]);
}
fn first_ids(s: &Session, n: usize) -> Vec<PhotoId> {
s.catalog.photos().take(n).map(|p| p.id).collect()
}
#[test]
fn composes_pages_from_real_photos() {
let mut s = Session::with_demo();
let ids = first_ids(&s, 3);
let mut set = settings(Style::ContactSheet);
set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false };
set.page.background = [200, 30, 30];
set.image.zoom_to_fill = true;
set.image.stroke_enabled = true;
set.image.stroke_width_pt = 2.0;
set.image.stroke_color = [0, 0, 0];
let work = prepare(&mut s, &set, &ids).unwrap();
assert_eq!((work.len(), work.pages()), (3, 2));
let mut steps = Vec::new();
let pages = work
.run(&mut |i, name| {
steps.push((i, name.to_string()));
true
})
.unwrap();
assert_eq!(steps.iter().map(|s| s.0).collect::<Vec<_>>(), vec![0, 1, 2, 3]);
assert_eq!(pages.len(), 2);
let first = &pages[0];
assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0));
assert_eq!(first.rgb.len(), 400 * 600 * 3);
let bg = rec2020_from_srgb8([200, 30, 30]);
let px = |p: &PageRaster, x: usize, y: usize| {
let i = (y * p.width as usize + x) * 3;
[p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
};
assert_eq!(px(first, 5, 5), bg, "margin shows the page background");
assert_eq!(px(first, 395, 595), bg);
// Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour.
assert_eq!(px(first, 50, 50), [0, 0, 0]);
let centre = px(first, 200, 150);
assert_ne!(centre, bg);
assert_ne!(centre, [0, 0, 0]);
// The second page holds the third photo in the first cell only: the second cell is bare.
let second = &pages[1];
assert_ne!(px(second, 200, 150), bg);
assert_eq!(px(second, 200, 450), bg);
// Cancelling gives no pages.
let work = prepare(&mut s, &set, &ids).unwrap();
assert!(work.run(&mut |i, _| i < 1).is_err());
}
#[test]
fn package_renders_each_size_and_border() {
let mut s = Session::with_demo();
let ids = first_ids(&s, 1);
let mut set = settings(Style::PicturePackage);
set.layout.add_cell(1.5, 1.0).unwrap();
set.layout.add_cell(1.5, 1.0).unwrap();
set.image.border_enabled = true;
set.image.border_width = 0.1;
set.image.border_color = [10, 200, 10];
let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap();
assert_eq!(pages.len(), 1);
let green = rec2020_from_srgb8([10, 200, 10]);
let cell = set.layout.cells[0].rect;
let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize);
let at = |x: usize, y: usize| {
let i = (y * 400 + x) * 3;
pages[0].rgb[i..i + 3].to_vec()
};
// The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round.
let mid = (x0 + 75, y0 + 50);
assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle");
let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count();
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
}
#[test]
fn caps_and_hostile_settings_are_refused() {
let mut s = Session::with_demo();
let ids = first_ids(&s, 1);
let mut set = settings(Style::SingleImage);
set.layout.page.paper = "custom".into();
set.layout.page.custom_width = 60.0;
set.layout.page.custom_height = 60.0;
set.job.ppi = 720.0;
assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap");
let mut set = settings(Style::SingleImage);
set.job.ppi = f32::NAN;
assert!(prepare(&mut s, &set, &ids).is_err());
let mut set = settings(Style::SingleImage);
set.image.border_width = f64::NAN;
assert!(prepare(&mut s, &set, &ids).is_err());
set.image.border_width = 0.1;
set.image.stroke_width_pt = -1.0;
assert!(prepare(&mut s, &set, &ids).is_err());
assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err());
assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err());
let set = settings(Style::SingleImage);
assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages");
// 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap.
let mut big = settings(Style::SingleImage);
big.job.ppi = 720.0;
assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err());
}
}
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
//! The Print module: page layout, composition of pages and PDF output.
//!
//! [`layout`] is the pure page model (paper, margins, cells); [`compose`] renders a layout and its
//! photos to [`PageRaster`]s; [`template`] holds the saved settings; [`output`] writes the pages
//! as a PDF.
pub mod compose;
pub mod layout;
pub mod output;
pub mod template;
use serde::{Deserialize, Serialize};
/// Lowest and highest print resolution, pixels per inch.
pub const MIN_PPI: f32 = 72.0;
pub const MAX_PPI: f32 = 720.0;
/// Pages a single print may have.
pub const MAX_JOB_PAGES: usize = 100;
/// Most pixels one page may have (about 100 megapixels: A3 at 720 ppi is 60).
pub const MAX_PAGE_PIXELS: u64 = 100_000_000;
/// Most pixels a print job may hold across all its pages (6 bytes each in memory).
pub const MAX_JOB_PIXELS: u64 = 200_000_000;
/// One composed page, ready for output: row-major interleaved RGB, 16 bits per sample, encoded in
/// Rec. 2020 ([`lightcraft_pipeline::OutputSpace::Rec2020`]; the transfer curve is that space's).
/// The page covers its paper edge to edge: `width / ppi` by `height / ppi` inches.
#[derive(Clone, Debug, PartialEq)]
pub struct PageRaster {
pub width: u32,
pub height: u32,
pub ppi: f32,
pub rgb: Vec<u16>,
}
/// How a print job is produced (Lightroom's Print Job panel): resolution, and the colour
/// management, print adjustment, print sharpening and depth of [`crate::print_color`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct PrintJob {
/// Print resolution in pixels per inch, 72 to 720.
pub ppi: f32,
/// Profile, rendering intent, black point compensation, print adjustment, print sharpening.
pub color: crate::print_color::PrintColorSettings,
}
impl Default for PrintJob {
fn default() -> Self {
Self { ppi: 240.0, color: crate::print_color::PrintColorSettings::default() }
}
}
impl PrintJob {
/// The resolution to render at: [`Self::ppi`] clamped to 72..=720 (240 for NaN).
pub fn effective_ppi(&self) -> f32 {
if self.ppi.is_finite() { self.ppi.clamp(MIN_PPI, MAX_PPI) } else { 240.0 }
}
/// Rejects a resolution outside the supported range (commands and templates, which should
/// say so rather than silently clamp).
pub fn validate(&self) -> Result<(), String> {
if !(self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi)) {
return Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}"));
}
self.color.validate()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ppi_is_clamped_and_validated() {
assert_eq!(PrintJob::default().effective_ppi(), 240.0);
for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] {
assert_eq!(PrintJob { ppi: v, ..Default::default() }.effective_ppi(), e);
}
assert!(PrintJob { ppi: 300.0, ..Default::default() }.validate().is_ok());
for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] {
assert!(PrintJob { ppi: bad, ..Default::default() }.validate().is_err());
}
let j: PrintJob = serde_json::from_str("{}").unwrap();
assert_eq!(j, PrintJob::default());
assert!(serde_json::from_str::<PrintJob>(r#"{"dpi": 1}"#).is_err());
// the colour settings ride along, defaults filled in, and are validated with the job
let j: PrintJob = serde_json::from_str(r#"{"ppi": 300, "color": {"intent": "relative", "brightness": 20}}"#).unwrap();
assert_eq!(j.color.intent, crate::print_color::PrintIntent::Relative);
assert!(j.color.black_point_compensation && j.validate().is_ok());
let j: PrintJob = serde_json::from_str(r#"{"color": {"contrast": 500}}"#).unwrap();
assert!(j.validate().is_err());
}
}
+136
View File
@@ -0,0 +1,136 @@
//! Writes composed pages as a print-ready PDF: each page is converted to the job's colour target
//! (a printer / paper profile, or sRGB / Adobe RGB when the printer manages colour) with its
//! intent, black point compensation and print adjustment ([`crate::print_color`]), then embedded
//! covering the paper as an ICC-tagged, losslessly compressed 16-bit (or 8-bit) image. A printer
//! profile is also named as the document's output intent ([`crate::print_pdf`]).
use super::{MAX_JOB_PAGES, PageRaster, PrintJob};
use crate::print_color::PrintColorJob;
use crate::print_pdf::ImageData;
use lightcraft_color::transfer::encode_srgb8;
use lightcraft_color::{REC2020, SRGB};
use lightcraft_pipeline::OutputSpace;
use lightcraft_raster::Rgba8;
/// Largest PDF produced, bytes.
const MAX_PDF_BYTES: usize = 256 * 1024 * 1024;
/// A Rec. 2020 page as 8-bit sRGB (out-of-gamut colours are clipped).
pub fn page_to_srgb8(page: &PageRaster) -> Result<Rgba8, String> {
let (w, h) = (page.width as usize, page.height as usize);
let n = w.checked_mul(h).ok_or("page too large")?;
if n == 0 || page.rgb.len() != n.checked_mul(3).ok_or("page too large")? {
return Err("a page's pixel data does not match its size".into());
}
let trc = OutputSpace::Rec2020.trc();
// 16-bit Rec. 2020 code → linear light, once per code value.
let lut: Vec<f32> = (0..=u16::MAX).map(|v| trc.decode(f32::from(v) / 65535.0)).collect();
let m = REC2020.to_space(&SRGB).to_f32();
let mut out = Rgba8::new(w, h);
for (dst, src) in out.data.iter_mut().zip(page.rgb.as_chunks::<3>().0) {
let l = src.map(|v| lut.get(usize::from(v)).copied().unwrap_or(0.0));
let row = |r: usize| m.get(r).map_or(0.0, |m| m[0] * l[0] + m[1] * l[1] + m[2] * l[2]);
*dst = [encode_srgb8(row(0)), encode_srgb8(row(1)), encode_srgb8(row(2)), 255];
}
Ok(out)
}
/// The PDF for `pages` (all the same size): one page per raster, its MediaBox the paper size in
/// points (`pixels / ppi * 72`) with the image covering it. Each page is converted and compressed
/// in turn, so memory holds one page's device samples at a time.
pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result<Vec<u8>, String> {
let first = pages.first().ok_or("nothing to print")?;
if pages.len() > MAX_JOB_PAGES {
return Err(format!("a print is limited to {MAX_JOB_PAGES} pages"));
}
if !(first.ppi.is_finite() && first.ppi > 0.0) {
return Err("a page has no resolution".into());
}
let points = |px: u32| f64::from(px) / f64::from(first.ppi) * 72.0;
let size = (points(first.width), points(first.height));
let color = PrintColorJob::new(&job.color)?;
let mut out = Vec::with_capacity(pages.len());
let mut bytes = 0usize;
for page in pages {
if (page.width, page.height) != (first.width, first.height) {
return Err("all pages of a PDF must be the same size".into());
}
let image = color.page_image(page.width, page.height, &page.rgb)?.deflated()?;
if let ImageData::Flate { data, .. } = &image.data {
bytes = bytes.saturating_add(data.len());
}
if bytes > MAX_PDF_BYTES {
return Err("the PDF would exceed 256 MiB; lower the print resolution, use 8 bits or print fewer pages".into());
}
out.push((size, image));
}
color.pdf(out, title)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::print::compose::rec2020_from_srgb8;
fn raster(w: u32, h: u32, srgb: [u8; 3]) -> PageRaster {
let c = rec2020_from_srgb8(srgb);
PageRaster { width: w, height: h, ppi: 100.0, rgb: (0..w * h).flat_map(|_| c).collect() }
}
#[test]
fn colours_survive_the_round_trip() {
for c in [[255u8, 255, 255], [0, 0, 0], [128, 128, 128], [200, 30, 30], [10, 200, 90]] {
let px = page_to_srgb8(&raster(2, 2, c)).unwrap().data[0];
for k in 0..3 {
assert!(px[k].abs_diff(c[k]) <= 1, "{c:?} -> {px:?}");
}
}
assert!(page_to_srgb8(&PageRaster { width: 2, height: 2, ppi: 100.0, rgb: vec![0; 5] }).is_err());
assert!(page_to_srgb8(&PageRaster { width: 0, height: 2, ppi: 100.0, rgb: vec![] }).is_err());
}
#[test]
fn pdf_pages_have_the_paper_size() {
let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])];
let bytes = write(&pages, &PrintJob::default(), "t").unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(text.starts_with("%PDF-"));
assert!(text.contains("/Count 2"));
let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len();
let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt");
assert!(text.contains("/Width 425 /Height 550"));
// managed by the printer: sRGB, 16 bits, tagged, no output intent
assert!(text.contains("/ICCBased") && text.contains("/BitsPerComponent 16") && !text.contains("/OutputIntents"));
assert!(write(&[], &PrintJob::default(), "t").is_err());
let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])];
assert!(write(&mixed, &PrintJob::default(), "t").is_err());
let bad = PageRaster { ppi: f32::NAN, ..raster(4, 4, [0; 3]) };
assert!(write(&[bad], &PrintJob::default(), "t").is_err());
let zero = PageRaster { ppi: 0.0, ..raster(4, 4, [0; 3]) };
assert!(write(&[zero], &PrintJob::default(), "t").is_err());
}
#[test]
fn a_printer_profile_is_converted_to_and_named_as_the_output_intent() {
use crate::print_color::{PrintColorSettings, PrintIntent, PrintProfile};
let dir = std::env::temp_dir().join(format!("lc-print-output-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let icc = dir.join("Lustre Paper.icc");
std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap();
let color =
PrintColorSettings { profile: PrintProfile::Icc { path: icc }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() };
let job = PrintJob { color, ..Default::default() };
let bytes = write(&[raster(20, 30, [200, 30, 30])], &job, "Lustre").unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/OutputIntents") && text.contains("/DestOutputProfile"), "{}", &text[..text.len().min(500)]);
assert!(text.contains("/BitsPerComponent 8") && text.contains("/Title (Lustre)"));
// a profile that isn't one fails the print and says which file
std::fs::write(dir.join("Junk.icc"), [7u8; 200]).unwrap();
let junk = PrintJob {
color: PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("Junk.icc") }, ..Default::default() },
..Default::default()
};
let err = write(&[raster(2, 2, [0; 3])], &junk, "t").unwrap_err();
assert!(err.contains("Junk"), "{err}");
}
}
+124
View File
@@ -0,0 +1,124 @@
//! The settings a print is made from, and (with the saved-template commands) what a print
//! template stores.
use super::PrintJob;
use super::compose::{ImageSettings, PageStyle};
use super::layout::{Guides, Layout};
use serde::{Deserialize, Serialize};
/// Layout, image settings, page, guides and job: the whole Print module state.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct PrintSettings {
pub layout: Layout,
pub image: ImageSettings,
pub page: PageStyle,
pub guides: Guides,
pub job: PrintJob,
}
impl PrintSettings {
/// Checks every part; run on anything that did not come from the setters.
pub fn validate(&mut self) -> Result<(), String> {
self.layout.validate()?;
self.image.validate()?;
self.guides.validate()?;
self.job.validate()
}
}
/// Longest template name, characters.
pub const MAX_NAME: usize = 80;
/// Most templates kept.
pub const MAX_TEMPLATES: usize = 200;
/// 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
/// JSON so a template written by another version never makes the preferences file unreadable;
/// [`PrintTemplate::settings`] checks them when they are used.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrintTemplate {
pub name: String,
pub settings: serde_json::Value,
}
/// A template name: trimmed, 1 to [`MAX_NAME`] characters, no control characters.
pub fn clean_name(name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) {
return Err(format!("a template name is 1 to {MAX_NAME} characters without control characters"));
}
Ok(name.to_string())
}
impl PrintTemplate {
/// A template of `settings` (validated first) named `name`.
pub fn new(name: &str, settings: &PrintSettings) -> Result<Self, String> {
let mut s = settings.clone();
s.validate()?;
for c in &mut s.layout.cells {
c.photo = None;
}
let mut value = serde_json::to_value(&s).map_err(|e| e.to_string())?;
if let Some(o) = value.as_object_mut() {
o.remove("guides");
}
Ok(Self { name: clean_name(name)?, settings: value })
}
/// The settings, checked: unknown fields, hostile numbers and inconsistent layouts are errors.
/// `guides` are those of the print the template is applied to.
pub fn settings(&self, guides: &Guides) -> Result<PrintSettings, String> {
let mut s: PrintSettings = serde_json::from_value(self.settings.clone()).map_err(|e| format!("template `{}`: {e}", self.name))?;
s.guides = *guides;
s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?;
Ok(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::print::layout::Style;
use serde_json::json;
#[test]
fn a_template_keeps_the_look_not_the_photos_or_guides() {
let mut s = PrintSettings::default();
s.layout.style = Style::CustomPackage;
let id = s.layout.add_cell(3.0, 2.0).unwrap();
s.layout.set_cell_photo(id, Some(77)).unwrap();
s.image.zoom_to_fill = true;
s.page.background = [10, 20, 30];
s.job.ppi = 300.0;
s.guides.grid_size = 1.0;
let t = PrintTemplate::new(" Proof sheet ", &s).unwrap();
assert_eq!(t.name, "Proof sheet");
assert!(t.settings.get("guides").is_none());
let back = t.settings(&Guides::default()).unwrap();
assert_eq!(back.layout.cells.len(), 1);
assert_eq!(back.layout.cells[0].photo, None);
assert!(back.image.zoom_to_fill);
assert_eq!((back.page.background, back.job.ppi), ([10, 20, 30], 300.0));
assert_eq!(back.guides, Guides::default(), "the guides are the caller's");
}
#[test]
fn hostile_templates_are_rejected() {
for name in ["", " ", "a\nb", &"x".repeat(MAX_NAME + 1)] {
assert!(PrintTemplate::new(name, &PrintSettings::default()).is_err(), "{name:?}");
}
let bad = |v: serde_json::Value| PrintTemplate { name: "t".into(), settings: v }.settings(&Guides::default()).is_err();
assert!(bad(json!("text")));
assert!(bad(json!({"surprise": 1})));
assert!(bad(json!({"job": {"ppi": 1e30}})));
assert!(bad(json!({"layout": {"pages": 0}})));
assert!(bad(json!({"layout": {"page": {"paper": "custom", "customWidth": -3}}})));
assert!(bad(json!({"layout": {"cells": [{"id": 1, "rect": {"x": 0, "y": 0, "w": 1e9, "h": 1}}]}})));
assert!(bad(json!({"image": {"borderWidth": -1}})));
assert!(!bad(json!({})));
let mut s = PrintSettings::default();
s.job.ppi = f32::NAN;
assert!(PrintTemplate::new("t", &s).is_err());
}
}
+386
View File
@@ -0,0 +1,386 @@
//! The print job's colour: which profile composed pages are converted to and how, print sharpening
//! for the paper, and the print-ready PDF they end up in.
//!
//! - **Profile**: "Managed by Printer" sends the page in sRGB or Adobe RGB with that profile
//! embedded (the driver converts); a printer / paper ICC profile converts the page to the device's
//! own values here and names the profile as the document's output intent, so the driver must not
//! convert again.
//! - **Intent, black point compensation, print adjustment**: [`lightcraft_codecs::print`].
//! - **Print sharpening** (Low / Standard / High for Matte or Glossy paper): each photo is sharpened
//! at its print size before it goes on the page, with the export sharpener.
//! - **Depth**: 16 bits per component by default (no banding from the profile conversion); 8 bits
//! for smaller files.
//!
//! Pages arrive as 16-bit RGB in the `Rec2020` output encoding, the composed page of the layout.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_codecs::display::MAX_PROFILE_BYTES;
use lightcraft_codecs::print::{PrintColor, PrintTarget, PrintTransform};
use lightcraft_codecs::{NamedSpace, proof::ProofIntent};
use serde::{Deserialize, Serialize};
use crate::export::{DeepImage, DeepSamples, OutputSpace, SharpenAmount, SharpenFor, output_sharpen_deep};
use crate::print_pdf::{ImageData, OutputIntent, PdfImage, PdfInfo, PdfPage, Placement};
/// The colour space a printer that manages colour receives.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ManagedSpace {
#[default]
Srgb,
AdobeRgb,
}
/// Where the print's colours go.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum PrintProfile {
/// Managed by Printer: the page goes in this space and the driver converts it.
Managed { space: ManagedSpace },
/// A printer / paper profile (an `.icc` / `.icm` file): converted here.
Icc { path: PathBuf },
}
impl Default for PrintProfile {
fn default() -> Self {
PrintProfile::Managed { space: ManagedSpace::Srgb }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PrintIntent {
#[default]
Perceptual,
Relative,
}
/// Print sharpening strength (Off leaves the photos as rendered).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PrintSharpen {
Off,
Low,
#[default]
Standard,
High,
}
/// The paper print sharpening is tuned for.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PrintMedia {
Matte,
#[default]
Glossy,
}
/// The print job's colour settings, as saved with print templates.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PrintColorSettings {
pub profile: PrintProfile,
pub intent: PrintIntent,
/// Map the page's black to the paper's darkest (relative colorimetric).
pub black_point_compensation: bool,
/// Print adjustment, −100..100.
pub brightness: f32,
/// Print adjustment, −100..100.
pub contrast: f32,
pub sharpen: PrintSharpen,
pub media: PrintMedia,
/// 16 bits per component in the PDF (else 8).
pub sixteen_bit: bool,
}
impl Default for PrintColorSettings {
fn default() -> Self {
PrintColorSettings {
profile: PrintProfile::default(),
intent: PrintIntent::default(),
black_point_compensation: true,
brightness: 0.0,
contrast: 0.0,
sharpen: PrintSharpen::default(),
media: PrintMedia::default(),
sixteen_bit: true,
}
}
}
impl PrintColorSettings {
/// Refuse settings a command or template shouldn't carry (out-of-range adjustments, an empty
/// profile path); whether the profile file is usable is checked when the job is built.
pub fn validate(&self) -> Result<(), String> {
for (name, v) in [("brightness", self.brightness), ("contrast", self.contrast)] {
if !(v.is_finite() && (-100.0..=100.0).contains(&v)) {
return Err(format!("{name} must be a number from -100 to 100"));
}
}
if let PrintProfile::Icc { path } = &self.profile
&& path.as_os_str().is_empty()
{
return Err("the printer profile needs a path".into());
}
Ok(())
}
/// Sharpen one photo for the paper, at the size it prints (before it goes on the page).
pub fn sharpen_photo(&self, photo: &mut DeepImage) {
let amount = match self.sharpen {
PrintSharpen::Off => return,
PrintSharpen::Low => SharpenAmount::Low,
PrintSharpen::Standard => SharpenAmount::Standard,
PrintSharpen::High => SharpenAmount::High,
};
let target = match self.media {
PrintMedia::Matte => SharpenFor::Matte,
PrintMedia::Glossy => SharpenFor::Glossy,
};
output_sharpen_deep(photo, target, amount);
}
}
/// Read a printer profile, refusing files that can't be one (the parse happens later).
fn read_profile(path: &Path) -> Result<Vec<u8>, String> {
let shown = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string());
let len = std::fs::metadata(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))?.len();
if len > MAX_PROFILE_BYTES as u64 {
return Err(format!("the printer profile {shown} is too large"));
}
std::fs::read(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))
}
/// A print job's colour, ready to apply: the conversion built once and what the PDF needs to know.
#[derive(Debug)]
pub struct PrintColorJob {
pub settings: PrintColorSettings,
transform: PrintTransform,
/// The printing condition named in the PDF (only for a printer profile).
intent: Option<OutputIntent>,
}
impl PrintColorJob {
/// Build the job's conversion. Errors name the profile and what's wrong with it.
pub fn new(settings: &PrintColorSettings) -> Result<PrintColorJob, String> {
let s = settings.clone();
let color = PrintColor {
intent: match s.intent {
PrintIntent::Perceptual => ProofIntent::Perceptual,
PrintIntent::Relative => ProofIntent::Relative,
},
black_point_compensation: s.black_point_compensation,
brightness: s.brightness,
contrast: s.contrast,
};
let (target, name) = match &s.profile {
PrintProfile::Managed { space } => {
(PrintTarget::Managed(if *space == ManagedSpace::AdobeRgb { NamedSpace::AdobeRgb } else { NamedSpace::Srgb }), None)
}
PrintProfile::Icc { path } => {
let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
(PrintTarget::Profile(Arc::new(read_profile(path)?)), Some(name))
}
};
let transform = PrintTransform::new(&target, &color).map_err(|e| match &name {
Some(n) => format!("the printer profile {n}: {e}"),
None => e,
})?;
let intent = name.map(|file| OutputIntent {
icc: Arc::clone(transform.icc()),
channels: transform.channels() as u8,
condition: transform.description().map(str::to_owned).unwrap_or(file),
});
Ok(PrintColorJob { settings: s, transform, intent })
}
/// Sharpen one photo for the paper ([`PrintColorSettings::sharpen_photo`]).
pub fn sharpen_photo(&self, photo: &mut DeepImage) {
self.settings.sharpen_photo(photo);
}
/// Convert a composed page (16-bit RGB, `Rec2020` output encoding) to the PDF image that covers
/// it, in the target's colours.
pub fn page_image(&self, width: u32, height: u32, rgb: &[u16]) -> Result<PdfImage, String> {
let want = u64::from(width).checked_mul(u64::from(height)).and_then(|p| p.checked_mul(3));
if width == 0 || height == 0 || want != Some(rgb.len() as u64) {
return Err(format!("a page of {width} × {height} pixels can't be made from {} samples", rgb.len()));
}
let device = self.transform.apply(rgb)?;
let channels = self.transform.channels() as u8;
let icc = Some(Arc::clone(self.transform.icc()));
Ok(if self.settings.sixteen_bit {
PdfImage::from_u16(width, height, channels, &device, icc)
} else {
let samples = device.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect();
PdfImage { width, height, channels, data: ImageData::Raw { bits: 8, samples }, icc }
})
}
/// The print-ready document: each page image covers its page (`size` in points).
pub fn pdf(&self, pages: Vec<((f64, f64), PdfImage)>, title: &str) -> Result<Vec<u8>, String> {
let pages: Vec<PdfPage> = pages
.into_iter()
.map(|((w, h), image)| PdfPage { width: w, height: h, images: vec![(image, Placement { x: 0.0, y: 0.0, width: w, height: h })] })
.collect();
let title = (!title.trim().is_empty()).then(|| title.to_owned());
crate::print_pdf::write(&pages, &PdfInfo { title, output_intent: self.intent.clone() })
}
/// Samples per pixel the pages are converted to (3 RGB, 4 CMYK, 1 grey).
pub fn channels(&self) -> usize {
self.transform.channels()
}
/// The profile's description (or file name) for the job's summary.
pub fn profile_name(&self) -> Option<&str> {
self.intent.as_ref().map(|i| i.condition.as_str()).or(self.transform.description())
}
}
/// A photo rendered for the page as the layout gets it from the export renderer.
pub fn is_print_ready(photo: &DeepImage) -> bool {
photo.space == OutputSpace::Rec2020 && matches!(photo.samples, DeepSamples::U16(_))
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_codecs::print::print_adjust;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("lc-print-color-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A small, flat page of one 16-bit colour.
fn page(w: u32, h: u32, v: [u16; 3]) -> Vec<u16> {
(0..w * h).flat_map(|_| v).collect()
}
#[test]
fn settings_default_and_round_trip() {
let d = PrintColorSettings::default();
assert_eq!(d.profile, PrintProfile::Managed { space: ManagedSpace::Srgb });
assert!(d.black_point_compensation && d.sixteen_bit);
let s = PrintColorSettings {
profile: PrintProfile::Icc { path: "/Library/ColorSync/Profiles/Paper.icc".into() },
intent: PrintIntent::Relative,
brightness: 12.0,
sharpen: PrintSharpen::High,
media: PrintMedia::Matte,
..d.clone()
};
let json = serde_json::to_value(&s).unwrap();
assert_eq!(json["profile"]["kind"], "icc");
assert_eq!(json["intent"], "relative");
assert_eq!(serde_json::from_value::<PrintColorSettings>(json).unwrap(), s);
// partial and unknown-free JSON fills in the defaults
let partial: PrintColorSettings = serde_json::from_str(r#"{"intent":"relative"}"#).unwrap();
assert_eq!(partial, PrintColorSettings { intent: PrintIntent::Relative, ..d });
assert!(serde_json::from_str::<PrintColorSettings>(r#"{"profile":{"kind":"cmyk"}}"#).is_err());
}
#[test]
fn managed_srgb_page_becomes_a_16_bit_srgb_pdf_without_an_output_intent() {
let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap();
let img = job.page_image(4, 2, &page(4, 2, [65535, 65535, 65535])).unwrap();
assert_eq!((img.channels, img.width, img.height), (3, 4, 2));
let ImageData::Raw { bits: 16, samples } = &img.data else { panic!("16-bit samples") };
assert!(samples.chunks(2).all(|b| u16::from_be_bytes([b[0], b[1]]) > 65400), "white stays white");
let pdf = job.pdf(vec![((288.0, 144.0), img)], "Prints").unwrap();
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("/ICCBased") && s.contains("/BitsPerComponent 16") && s.contains("/MediaBox [0 0 288 144]"));
assert!(!s.contains("/OutputIntents"), "the printer converts: no output intent");
assert!(s.contains("/Title (Prints)"));
}
#[test]
fn a_printer_profile_converts_and_becomes_the_output_intent() {
let dir = scratch("icc");
let path = dir.join("Studio Glossy.icc");
std::fs::write(&path, lightcraft_codecs::icc::write_named(NamedSpace::AdobeRgb)).unwrap();
let settings =
PrintColorSettings { profile: PrintProfile::Icc { path }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() };
let job = PrintColorJob::new(&settings).unwrap();
assert_eq!(job.channels(), 3);
let img = job.page_image(2, 2, &page(2, 2, [0, 65535, 0])).unwrap();
assert!(matches!(&img.data, ImageData::Raw { bits: 8, .. }));
let pdf = job.pdf(vec![((100.0, 100.0), img)], "").unwrap();
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("/OutputIntents") && s.contains("/BitsPerComponent 8"), "{}", &s[..s.len().min(600)]);
assert!(!s.contains("/Title"), "an empty title is left out");
assert!(job.profile_name().is_some());
}
#[test]
fn profile_errors_name_the_file() {
let dir = scratch("bad");
let garbage = dir.join("Broken Paper.icc");
std::fs::write(&garbage, [0u8; 300]).unwrap();
let err = PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: garbage }, ..Default::default() }).unwrap_err();
assert!(err.contains("Broken Paper"), "{err}");
let missing =
PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("nope.icc") }, ..Default::default() }).unwrap_err();
assert!(missing.contains("nope.icc"), "{missing}");
}
#[test]
fn print_adjustment_reaches_the_page() {
let mid = [32768u16; 3];
let plain = PrintColorJob::new(&PrintColorSettings::default()).unwrap().page_image(1, 1, &mid).unwrap();
let bright = PrintColorJob::new(&PrintColorSettings { brightness: 60.0, ..Default::default() }).unwrap().page_image(1, 1, &mid).unwrap();
let first = |i: &PdfImage| match &i.data {
ImageData::Raw { samples, .. } => u16::from_be_bytes([samples[0], samples[1]]),
ImageData::Jpeg(_) | ImageData::Flate { .. } => 0,
};
assert!(first(&bright) > first(&plain) + 1500, "{} vs {}", first(&bright), first(&plain));
assert!(print_adjust(0.5, 60.0, 0.0) > 0.5);
}
#[test]
fn print_sharpening_follows_the_setting() {
// a hard vertical edge in a 16-bit Rec2020 photo
let (w, h) = (8usize, 4usize);
let samples: Vec<u16> = (0..w * h).flat_map(|i| if i % w < w / 2 { [20000u16; 3] } else { [40000u16; 3] }).collect();
let photo = DeepImage { width: w, height: h, space: OutputSpace::Rec2020, samples: DeepSamples::U16(samples) };
assert!(is_print_ready(&photo));
let sharpened = |s: PrintSharpen| {
let job = PrintColorJob::new(&PrintColorSettings { sharpen: s, media: PrintMedia::Matte, ..Default::default() }).unwrap();
let mut p = photo.clone();
job.sharpen_photo(&mut p);
p
};
assert_eq!(sharpened(PrintSharpen::Off), photo);
let edge = |p: &DeepImage| match &p.samples {
DeepSamples::U16(v) => (v[(w / 2 - 1) * 3], v[(w / 2) * 3]),
DeepSamples::F32(_) => (0, 0),
};
let (dark, light) = edge(&sharpened(PrintSharpen::High));
assert!(dark < 20000 && light > 40000, "the edge gets more contrast: {dark} {light}");
}
#[test]
fn validation_refuses_out_of_range_settings() {
assert!(PrintColorSettings::default().validate().is_ok());
for (b, c) in [(101.0, 0.0), (0.0, -100.5), (f32::NAN, 0.0), (0.0, f32::INFINITY)] {
assert!(PrintColorSettings { brightness: b, contrast: c, ..Default::default() }.validate().is_err(), "{b} {c}");
}
assert!(PrintColorSettings { profile: PrintProfile::Icc { path: PathBuf::new() }, ..Default::default() }.validate().is_err());
assert!(PrintColorSettings { brightness: -100.0, contrast: 100.0, ..Default::default() }.validate().is_ok());
}
#[test]
fn hostile_pages_are_errors() {
let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap();
assert!(job.page_image(0, 1, &[]).is_err());
assert!(job.page_image(2, 2, &[0; 11]).is_err());
assert!(job.page_image(u32::MAX, u32::MAX, &[0; 3]).is_err());
let img = job.page_image(1, 1, &[0; 3]).unwrap();
assert!(job.pdf(vec![((f64::NAN, 10.0), img)], "x").is_err());
assert!(job.pdf(vec![], "x").is_err());
}
}
+520
View File
@@ -0,0 +1,520 @@
//! A small PDF writer for print output: pages of a given size in points, each with raster images
//! placed on it, tagged with their ICC profile (`ICCBased`) so the numbers mean the same colour in
//! every reader and RIP, Flate-compressed (lossless) at 8 or 16 bits per component, or carried as
//! JPEG (`DCTDecode`) where small files matter more than exact samples. Optionally an output intent:
//! the printing condition (the printer / paper profile) the file was made for, as PDF/X readers
//! expect it in the catalog. Profiles shared by several images are written once.
//!
//! The file is PDF 1.6 (16-bit images need 1.5): classic cross-reference table, no object streams,
//! deterministic (no dates or IDs), so the same pages always give the same bytes.
//!
//! Sources: ISO 32000-1:2008 (§7.3 objects, §7.5 file structure, §8.6.5.5 ICCBased colour spaces,
//! §8.9 images, §14.11.5 output intents). Own design.
use std::collections::HashMap;
use std::sync::Arc;
/// Pages per document: a print job beyond this is a mistake, not a book.
pub const MAX_PAGES: usize = 1000;
/// Images per page.
pub const MAX_IMAGES_PER_PAGE: usize = 256;
/// Largest page side in points (200 inches): more than any printer's roll.
pub const MAX_PAGE_POINTS: f64 = 14_400.0;
/// Largest image, in pixels per side.
pub const MAX_IMAGE_SIDE: u32 = 65_535;
/// How an image's samples are stored.
#[derive(Clone, Debug, PartialEq)]
pub enum ImageData {
/// Interleaved samples, `bits` (8 or 16) per component, 16-bit values big-endian: compressed
/// losslessly in the file.
Raw { bits: u8, samples: Vec<u8> },
/// A baseline JPEG stream, copied into the file as it is.
Jpeg(Vec<u8>),
/// Samples already compressed with zlib (see [`PdfImage::deflated`]), `bits` per component.
Flate { bits: u8, data: Vec<u8> },
}
/// A raster image and the colour space its samples are in.
#[derive(Clone, Debug, PartialEq)]
pub struct PdfImage {
pub width: u32,
pub height: u32,
/// Components per pixel: 1 (grey), 3 (RGB) or 4 (CMYK).
pub channels: u8,
pub data: ImageData,
/// The ICC profile the samples are in; without one the image is device colour.
pub icc: Option<Arc<Vec<u8>>>,
}
impl PdfImage {
/// An image from interleaved 16-bit samples.
pub fn from_u16(width: u32, height: u32, channels: u8, samples: &[u16], icc: Option<Arc<Vec<u8>>>) -> PdfImage {
let bytes = samples.iter().flat_map(|v| v.to_be_bytes()).collect();
PdfImage { width, height, channels, data: ImageData::Raw { bits: 16, samples: bytes }, icc }
}
/// The same image with its samples compressed now (checked first), so a long print doesn't
/// hold every page's uncompressed samples until the file is written.
pub fn deflated(self) -> Result<PdfImage, String> {
self.check()?;
let data = match self.data {
ImageData::Raw { bits, samples } => ImageData::Flate { bits, data: deflate(&samples) },
other => other,
};
Ok(PdfImage { data, ..self })
}
fn check(&self) -> Result<(), String> {
if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE {
return Err(format!("an image of {} × {} pixels can't be written", self.width, self.height));
}
if !matches!(self.channels, 1 | 3 | 4) {
return Err(format!("images have 1, 3 or 4 components, not {}", self.channels));
}
match &self.data {
ImageData::Raw { bits, samples } => {
if !matches!(bits, 8 | 16) {
return Err(format!("images have 8 or 16 bits per component, not {bits}"));
}
let want = u64::from(self.width) * u64::from(self.height) * u64::from(self.channels) * u64::from(bits / 8);
if samples.len() as u64 != want {
return Err(format!("the image needs {want} bytes of samples, not {}", samples.len()));
}
}
ImageData::Jpeg(data) => {
if !data.starts_with(&[0xFF, 0xD8]) {
return Err("the image isn't a JPEG stream".into());
}
}
ImageData::Flate { bits, data } => {
if !matches!(bits, 8 | 16) || data.is_empty() {
return Err("a compressed image has 8 or 16 bits per component and some data".into());
}
}
}
Ok(())
}
}
/// Where an image goes on its page, in points from the page's bottom-left corner.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placement {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
/// One page: its size in points and the images on it, drawn in order (later ones on top).
#[derive(Clone, Debug, PartialEq)]
pub struct PdfPage {
pub width: f64,
pub height: f64,
pub images: Vec<(PdfImage, Placement)>,
}
/// The printing condition the document is made for (PDF/X-style output intent).
#[derive(Clone, Debug, PartialEq)]
pub struct OutputIntent {
/// The printer / paper profile.
pub icc: Arc<Vec<u8>>,
/// Its components: 1, 3 or 4.
pub channels: u8,
/// A name for the condition (the profile's description).
pub condition: String,
}
/// Document-level settings.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PdfInfo {
pub title: Option<String>,
pub output_intent: Option<OutputIntent>,
}
fn finite(v: f64) -> bool {
v.is_finite()
}
/// A number as PDF writes it: up to four decimals, no exponent, no trailing zeros.
fn num(v: f64) -> String {
let s = format!("{:.4}", if finite(v) { v } else { 0.0 });
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" || s.is_empty() { "0".into() } else { s.into() }
}
/// A text string: plain ASCII as a literal string, anything else as UTF-16BE with a byte order mark.
fn text(s: &str) -> String {
if s.bytes().all(|b| (0x20..0x7f).contains(&b)) {
let mut out = String::with_capacity(s.len() + 2);
out.push('(');
for c in s.chars() {
if matches!(c, '(' | ')' | '\\') {
out.push('\\');
}
out.push(c);
}
out.push(')');
out
} else {
let mut out = String::from("<FEFF");
for unit in s.encode_utf16() {
out.push_str(&format!("{unit:04X}"));
}
out.push('>');
out
}
}
struct Writer {
out: Vec<u8>,
offsets: Vec<usize>,
}
impl Writer {
/// Reserve an object number (filled later by [`Writer::object`]).
fn reserve(&mut self) -> usize {
self.offsets.push(0);
self.offsets.len()
}
fn object(&mut self, id: usize, dict: &str, stream: Option<&[u8]>) {
if let Some(slot) = self.offsets.get_mut(id.wrapping_sub(1)) {
*slot = self.out.len();
}
self.out.extend_from_slice(format!("{id} 0 obj\n{dict}").as_bytes());
if let Some(data) = stream {
self.out.extend_from_slice(b"\nstream\n");
self.out.extend_from_slice(data);
self.out.extend_from_slice(b"\nendstream");
}
self.out.extend_from_slice(b"\nendobj\n");
}
}
fn deflate(data: &[u8]) -> Vec<u8> {
miniz_oxide::deflate::compress_to_vec_zlib(data, 6)
}
/// Write a document. Errors name the first page or image that can't be written.
pub fn write(pages: &[PdfPage], info: &PdfInfo) -> Result<Vec<u8>, String> {
if pages.is_empty() {
return Err("there are no pages to write".into());
}
if pages.len() > MAX_PAGES {
return Err(format!("a document holds at most {MAX_PAGES} pages"));
}
for (n, page) in pages.iter().enumerate() {
let n = n + 1;
if !(finite(page.width) && finite(page.height))
|| page.width <= 0.0
|| page.height <= 0.0
|| page.width > MAX_PAGE_POINTS
|| page.height > MAX_PAGE_POINTS
{
return Err(format!("page {n} has no usable size"));
}
if page.images.len() > MAX_IMAGES_PER_PAGE {
return Err(format!("page {n} has more than {MAX_IMAGES_PER_PAGE} images"));
}
for (image, at) in &page.images {
image.check().map_err(|e| format!("page {n}: {e}"))?;
if ![at.x, at.y, at.width, at.height].into_iter().all(finite) || at.width <= 0.0 || at.height <= 0.0 {
return Err(format!("page {n}: an image has no usable placement"));
}
}
}
if let Some(intent) = &info.output_intent
&& !matches!(intent.channels, 1 | 3 | 4)
{
return Err("the output intent's profile has 1, 3 or 4 components".into());
}
let mut w = Writer { out: Vec::new(), offsets: Vec::new() };
w.out.extend_from_slice(b"%PDF-1.6\n%\xE2\xE3\xCF\xD3\n");
let catalog = w.reserve();
let pages_id = w.reserve();
let info_id = w.reserve();
// ICC profiles, each written once (keyed by content) with its component count
let mut profiles: HashMap<Vec<u8>, usize> = HashMap::new();
let mut profile = |w: &mut Writer, icc: &Arc<Vec<u8>>, n: u8| -> usize {
if let Some(&id) = profiles.get(icc.as_slice()) {
return id;
}
let id = w.reserve();
let data = deflate(icc);
let alternate = match n {
1 => "/DeviceGray",
4 => "/DeviceCMYK",
_ => "/DeviceRGB",
};
w.object(id, &format!("<< /N {n} /Alternate {alternate} /Filter /FlateDecode /Length {} >>", data.len()), Some(&data));
profiles.insert(icc.to_vec(), id);
id
};
let intent_id = info.output_intent.as_ref().map(|intent| profile(&mut w, &intent.icc, intent.channels));
let mut kids = Vec::with_capacity(pages.len());
for page in pages {
let page_id = w.reserve();
let mut xobjects = String::new();
let mut content = String::new();
for (k, (image, at)) in page.images.iter().enumerate() {
let space = match &image.icc {
Some(icc) => format!("[/ICCBased {} 0 R]", profile(&mut w, icc, image.channels)),
None => match image.channels {
1 => "/DeviceGray".into(),
4 => "/DeviceCMYK".into(),
_ => "/DeviceRGB".into(),
},
};
let id = w.reserve();
let (bits, filter, data): (u8, &str, std::borrow::Cow<[u8]>) = match &image.data {
ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples).into()),
ImageData::Flate { bits, data } => (*bits, "/FlateDecode", data.as_slice().into()),
ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.as_slice().into()),
};
w.object(
id,
&format!(
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {space} /BitsPerComponent {bits} /Filter {filter} /Length {} >>",
image.width,
image.height,
data.len()
),
Some(&data),
);
xobjects.push_str(&format!("/Im{k} {id} 0 R "));
content.push_str(&format!("q {} 0 0 {} {} {} cm /Im{k} Do Q\n", num(at.width), num(at.height), num(at.x), num(at.y)));
}
let content_id = w.reserve();
let stream = deflate(content.as_bytes());
w.object(content_id, &format!("<< /Filter /FlateDecode /Length {} >>", stream.len()), Some(&stream));
w.object(
page_id,
&format!(
"<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {} {}] /Resources << /XObject << {xobjects}>> >> /Contents {content_id} 0 R >>",
num(page.width),
num(page.height)
),
None,
);
kids.push(format!("{page_id} 0 R"));
}
w.object(pages_id, &format!("<< /Type /Pages /Count {} /Kids [{}] >>", kids.len(), kids.join(" ")), None);
let intents = match (&info.output_intent, intent_id) {
(Some(intent), Some(id)) => format!(
" /OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Custom) /Info {} /DestOutputProfile {id} 0 R >>]",
text(&intent.condition)
),
_ => String::new(),
};
w.object(catalog, &format!("<< /Type /Catalog /Pages {pages_id} 0 R{intents} >>"), None);
let title = info.title.as_deref().map(|t| format!(" /Title {}", text(t))).unwrap_or_default();
w.object(info_id, &format!("<< /Producer (LightCraft){title} >>"), None);
let xref = w.out.len();
let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", w.offsets.len() + 1);
for offset in &w.offsets {
table.push_str(&format!("{offset:010} 00000 n \n"));
}
table.push_str(&format!("trailer\n<< /Size {} /Root {catalog} 0 R /Info {info_id} 0 R >>\nstartxref\n{xref}\n%%EOF\n", w.offsets.len() + 1));
w.out.extend_from_slice(table.as_bytes());
Ok(w.out)
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_codecs::NamedSpace;
/// The body of object `id` (dictionary and raw stream bytes), found through the xref table.
fn object(pdf: &[u8], id: usize) -> (String, Vec<u8>) {
// byte offsets: the streams are binary, so search the bytes, not a lossy string
let start = pdf.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
let tail = std::str::from_utf8(&pdf[start..]).unwrap();
let xref: usize = tail.lines().next().unwrap().trim().parse().unwrap();
let table = std::str::from_utf8(&pdf[xref..]).unwrap();
let line = table.lines().nth(2 + id).unwrap();
let offset: usize = line[..10].parse().unwrap();
let head = format!("{id} 0 obj\n");
assert_eq!(&pdf[offset..offset + head.len()], head.as_bytes(), "xref entry for {id}");
let rest = &pdf[offset + head.len()..];
let end = rest.windows(7).position(|w| w == b"\nendobj").unwrap();
let body = &rest[..end];
match body.windows(8).position(|w| w == b"\nstream\n") {
Some(p) => {
let data = &body[p + 8..body.len() - "\nendstream".len()];
(String::from_utf8_lossy(&body[..p]).into_owned(), data.to_vec())
}
None => (String::from_utf8_lossy(body).into_owned(), vec![]),
}
}
fn inflate(data: &[u8]) -> Vec<u8> {
miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()
}
/// Object ids of every image XObject, in file order.
fn images(pdf: &[u8]) -> Vec<usize> {
let s = String::from_utf8_lossy(pdf);
s.match_indices(" 0 obj\n<< /Type /XObject /Subtype /Image").map(|(i, _)| s[..i].rsplit('\n').next().unwrap().parse().unwrap()).collect()
}
fn reference(dict: &str, key: &str) -> usize {
let at = dict.find(key).unwrap() + key.len();
dict[at..].trim_start().split(' ').next().unwrap().parse().unwrap()
}
fn srgb() -> Arc<Vec<u8>> {
Arc::new(lightcraft_codecs::icc::write_named(NamedSpace::Srgb))
}
fn page(images: Vec<(PdfImage, Placement)>) -> PdfPage {
PdfPage { width: 612.0, height: 792.0, images }
}
fn full(w: f64, h: f64) -> Placement {
Placement { x: 0.0, y: 0.0, width: w, height: h }
}
#[test]
fn a_16_bit_icc_image_round_trips() {
let samples: Vec<u16> = (0..2 * 3 * 3).map(|i| (i * 3851) as u16).collect();
let img = PdfImage::from_u16(3, 2, 3, &samples, Some(srgb()));
let pdf = write(&[page(vec![(img, full(612.0, 792.0))])], &PdfInfo::default()).unwrap();
assert!(pdf.starts_with(b"%PDF-1.6\n"));
assert!(pdf.ends_with(b"%%EOF\n"));
let ids = images(&pdf);
assert_eq!(ids.len(), 1);
let (dict, data) = object(&pdf, ids[0]);
assert!(dict.contains("/Width 3 /Height 2") && dict.contains("/BitsPerComponent 16") && dict.contains("/FlateDecode"), "{dict}");
let back: Vec<u16> = inflate(&data).chunks(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect();
assert_eq!(back, samples);
let (profile, icc) = object(&pdf, reference(&dict, "/ICCBased"));
assert!(profile.contains("/N 3") && profile.contains("/Alternate /DeviceRGB"), "{profile}");
assert_eq!(inflate(&icc), *srgb());
assert_eq!(lightcraft_codecs::icc::parse(&inflate(&icc)).and_then(|i| i.named), Some(NamedSpace::Srgb));
}
#[test]
fn pages_sizes_placements_and_a_shared_profile() {
let px = |v: u16| PdfImage::from_u16(1, 1, 3, &[v, v, v], Some(srgb()));
let at = Placement { x: 36.0, y: 72.5, width: 288.0, height: 216.0 };
let pages = [page(vec![(px(1), at), (px(2), full(612.0, 792.0))]), PdfPage { width: 841.89, height: 595.28, images: vec![(px(3), at)] }];
let pdf = write(&pages, &PdfInfo::default()).unwrap();
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("/Type /Pages /Count 2"), "two pages");
assert!(s.contains("/MediaBox [0 0 612 792]") && s.contains("/MediaBox [0 0 841.89 595.28]"));
// the sRGB profile is written once for all three images
assert_eq!(s.matches("/N 3 /Alternate").count(), 1);
let first: Vec<_> = images(&pdf).iter().map(|&id| reference(&object(&pdf, id).0, "/ICCBased")).collect();
assert!(first.windows(2).all(|w| w[0] == w[1]), "{first:?}");
// placements reach the content streams
let contents: Vec<String> = s
.match_indices("/Contents ")
.map(|(i, _)| s[i + 10..].split(' ').next().unwrap().parse::<usize>().unwrap())
.map(|id| String::from_utf8(inflate(&object(&pdf, id).1)).unwrap())
.collect();
assert_eq!(contents[0], "q 288 0 0 216 36 72.5 cm /Im0 Do Q\nq 612 0 0 792 0 0 cm /Im1 Do Q\n");
assert_eq!(write(&pages, &PdfInfo::default()).unwrap(), pdf, "deterministic");
}
#[test]
fn output_intent_and_title() {
let cmyk_like = srgb();
let info = PdfInfo {
title: Some("Garden – prints (2)".into()),
output_intent: Some(OutputIntent { icc: cmyk_like.clone(), channels: 3, condition: "Glossy (Photo) \\ test".into() }),
};
let img = PdfImage::from_u16(1, 1, 3, &[0, 0, 0], Some(cmyk_like));
let pdf = write(&[page(vec![(img, full(10.0, 10.0))])], &info).unwrap();
let (catalog, _) = object(&pdf, 1);
assert!(catalog.contains("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX"), "{catalog}");
assert!(catalog.contains("/Info (Glossy \\(Photo\\) \\\\ test)"), "{catalog}");
// the intent and the image share one profile object
let dest = reference(&catalog, "/DestOutputProfile");
assert_eq!(reference(&object(&pdf, images(&pdf)[0]).0, "/ICCBased"), dest);
let (info_dict, _) = object(&pdf, 3);
assert!(info_dict.contains("/Title <FEFF"), "non-ASCII titles are UTF-16: {info_dict}");
assert!(info_dict.contains("2013"), "the en dash: {info_dict}");
}
#[test]
fn cmyk_grey_jpeg_and_device_images() {
let jpeg = vec![0xFF, 0xD8, 0xFF, 0xD9];
let imgs = vec![
(
PdfImage { width: 1, height: 1, channels: 4, data: ImageData::Raw { bits: 8, samples: vec![0, 10, 20, 30] }, icc: None },
full(1.0, 1.0),
),
(PdfImage { width: 2, height: 1, channels: 1, data: ImageData::Raw { bits: 8, samples: vec![0, 255] }, icc: None }, full(1.0, 1.0)),
(PdfImage { width: 4, height: 4, channels: 3, data: ImageData::Jpeg(jpeg.clone()), icc: None }, full(1.0, 1.0)),
];
let pdf = write(&[page(imgs)], &PdfInfo::default()).unwrap();
let ids = images(&pdf);
let dicts: Vec<_> = ids.iter().map(|&id| object(&pdf, id)).collect();
assert!(dicts[0].0.contains("/ColorSpace /DeviceCMYK") && dicts[0].0.contains("/BitsPerComponent 8"));
assert!(dicts[1].0.contains("/ColorSpace /DeviceGray"));
assert!(dicts[2].0.contains("/DCTDecode") && dicts[2].0.contains("/DeviceRGB"));
assert_eq!(dicts[2].1, jpeg, "JPEG streams are copied as they are");
}
#[test]
fn compressing_early_writes_the_same_file() {
let samples: Vec<u16> = (0..4 * 3 * 3).map(|i| (i * 977) as u16).collect();
let raw = PdfImage::from_u16(4, 3, 3, &samples, Some(srgb()));
let early = raw.clone().deflated().unwrap();
assert!(matches!(early.data, ImageData::Flate { bits: 16, .. }));
let at = full(100.0, 75.0);
let a = write(&[page(vec![(raw, at)])], &PdfInfo::default()).unwrap();
let b = write(&[page(vec![(early, at)])], &PdfInfo::default()).unwrap();
assert_eq!(a, b);
let mut short = PdfImage::from_u16(4, 3, 3, &samples, None);
short.width = 5;
assert!(short.deflated().is_err(), "checked before compressing");
}
#[test]
fn hostile_documents_are_errors() {
let ok = || PdfImage::from_u16(1, 1, 3, &[1, 2, 3], None);
let at = full(10.0, 10.0);
let bad = |pages: Vec<PdfPage>| write(&pages, &PdfInfo::default()).is_err();
assert!(bad(vec![]));
assert!(bad(vec![PdfPage { width: f64::NAN, height: 10.0, images: vec![] }]));
assert!(bad(vec![PdfPage { width: -1.0, height: 10.0, images: vec![] }]));
assert!(bad(vec![PdfPage { width: 1e9, height: 10.0, images: vec![] }]));
assert!(bad(vec![page(vec![(ok(), Placement { x: f64::INFINITY, ..at })])]));
assert!(bad(vec![page(vec![(ok(), Placement { width: 0.0, ..at })])]));
let mut short = ok();
short.data = ImageData::Raw { bits: 16, samples: vec![0; 5] };
assert!(bad(vec![page(vec![(short, at)])]));
let mut empty = ok();
empty.width = 0;
assert!(bad(vec![page(vec![(empty, at)])]));
let mut odd = ok();
odd.channels = 2;
assert!(bad(vec![page(vec![(odd, at)])]));
let mut not_jpeg = ok();
not_jpeg.data = ImageData::Jpeg(vec![1, 2, 3]);
assert!(bad(vec![page(vec![(not_jpeg, at)])]));
let too_many = (0..MAX_PAGES + 1).map(|_| page(vec![])).collect();
assert!(bad(too_many));
let info = PdfInfo { title: None, output_intent: Some(OutputIntent { icc: srgb(), channels: 2, condition: String::new() }) };
assert!(write(&[page(vec![])], &info).is_err());
}
#[test]
fn numbers_and_strings() {
assert_eq!(num(612.0), "612");
assert_eq!(num(72.5), "72.5");
assert_eq!(num(-0.00001), "0");
assert_eq!(num(f64::NAN), "0");
assert_eq!(num(1.0 / 3.0), "0.3333");
assert_eq!(text("a(b)c\\"), "(a\\(b\\)c\\\\)");
assert_eq!(text("é"), "<FEFF00E9>");
}
}
+205
View File
@@ -0,0 +1,205 @@
//! Profiles named by imported presets (issue #1001).
//!
//! A Lightroom preset doesn't carry its profile's look: it names it (`crs:Look`: name, group,
//! UUID, amount), and many presets get most of their look from that profile. On import the
//! reference is kept in the preset's settings as `profile.look`, and resolved against the
//! profiles the user has imported (by name, the group breaking ties), so the preset applies the
//! profile like the other app does. The six basic profiles of the other app map to our own basic
//! profiles; its other built-in profiles can't be used here and the import says so.
//!
//! References are resolved again whenever profiles are imported or deleted, so a pack works in
//! either order. Only the user's own files are read; no profile content ships with LightCraft.
use serde_json::{Value, json};
use crate::Session;
use crate::cmd::lut_profiles::LutProfile;
use crate::crs::Props;
/// The profile named by a preset's `crs:Look`, as kept in its settings (`profile.look`):
/// `{name, group?, uuid?, amount}` (amount in percent, like [`lightcraft_develop::Profile`]).
pub fn look_ref(props: &Props) -> Option<Value> {
let field = |k: &str| props.get(&format!("crs:Look/crs:{k}")).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty());
let name = field("Name")?;
// stored as a fraction (1 = 100 %), 0–2
let amount = field("Amount").and_then(|a| a.parse::<f64>().ok()).filter(|a| a.is_finite()).map_or(100.0, |a| (a * 100.0).clamp(0.0, 200.0));
let mut look = json!({"name": name, "amount": amount});
if let Some(g) = field("Group") {
look["group"] = json!(g);
}
if let Some(u) = field("UUID") {
look["uuid"] = json!(u.to_ascii_lowercase());
}
Some(look)
}
/// What a preset's profile reference resolved to.
#[derive(Clone, Debug, PartialEq)]
pub enum Resolved {
/// A profile the user imported (`lut:…`).
Imported(String),
/// One of our built-in profiles standing in for the other app's basic profile of that name.
BuiltIn(String),
/// One of the other app's own creative or camera profiles: not available here.
OtherAppBuiltIn,
/// A third-party profile the user hasn't imported (yet).
NotImported,
}
fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
}
/// The other app's basic profiles and ours that stand in for them.
const BASIC: &[(&str, &str)] = &[
("adobe color", "lc.color"),
("adobe neutral", "lc.neutral"),
("adobe vivid", "lc.vivid"),
("adobe landscape", "lc.landscape"),
("adobe portrait", "lc.portrait"),
("adobe monochrome", "lc.mono"),
];
/// The other app's own profiles: everything named `Adobe …` or `Camera …`, and its numbered
/// creative profiles (`Artistic 01`, `B&W 05`, `Modern 10`, `Vintage 02`).
fn other_app_builtin(name: &str) -> bool {
let n = norm(name);
if n.starts_with("adobe ") || n.starts_with("camera ") {
return true;
}
["artistic ", "b&w ", "modern ", "vintage "]
.iter()
.any(|p| n.strip_prefix(p).is_some_and(|rest| !rest.is_empty() && rest.len() <= 2 && rest.chars().all(|c| c.is_ascii_digit())))
}
/// Resolve `look` (a `profile.look` value) against the imported profiles.
pub fn resolve(look: &Value, luts: &[LutProfile]) -> Resolved {
let name = norm(look.get("name").and_then(Value::as_str).unwrap_or_default());
let group = look.get("group").and_then(Value::as_str).map(norm);
if !name.is_empty() {
let same_name: Vec<&LutProfile> = luts.iter().filter(|p| norm(&p.name) == name).collect();
let pick = same_name.iter().find(|p| group.as_deref() == Some(norm(&p.group).as_str())).or(same_name.first());
if let Some(p) = pick {
return Resolved::Imported(p.id.clone());
}
}
if let Some((_, ours)) = BASIC.iter().find(|(theirs, _)| *theirs == name) {
return Resolved::BuiltIn((*ours).to_string());
}
if other_app_builtin(&name) { Resolved::OtherAppBuiltIn } else { Resolved::NotImported }
}
/// Set (or clear) `profile.id` / `profile.amount` in a preset's partial settings from its
/// `profile.look`. Returns what the reference resolved to (`None`: the preset names no profile)
/// and whether `settings` changed.
pub fn apply(settings: &mut Value, luts: &[LutProfile]) -> Option<(Resolved, bool)> {
let profile = settings.get_mut("profile")?.as_object_mut()?;
let look = profile.get("look")?.clone();
let r = resolve(&look, luts);
let before = (profile.get("id").cloned(), profile.get("amount").cloned());
match &r {
Resolved::Imported(id) | Resolved::BuiltIn(id) => {
profile.insert("id".into(), json!(id));
profile.insert("amount".into(), look.get("amount").cloned().unwrap_or(json!(100.0)));
}
// the photo keeps its own profile
Resolved::OtherAppBuiltIn | Resolved::NotImported => {
profile.remove("id");
profile.remove("amount");
}
}
let changed = before != (profile.get("id").cloned(), profile.get("amount").cloned());
Some((r, changed))
}
/// How the import reports a reference it couldn't resolve (one of the preset's `unmapped` names).
pub fn report(look: &Value, r: &Resolved) -> Option<String> {
let name = look.get("name").and_then(Value::as_str).unwrap_or_default();
match r {
Resolved::Imported(_) | Resolved::BuiltIn(_) => None,
Resolved::OtherAppBuiltIn => Some(format!("Profile “{name}” (Lightroom's own)")),
Resolved::NotImported => Some(format!("Profile “{name}” (not imported yet)")),
}
}
/// Resolve every user preset's profile reference again (after profiles were imported or deleted).
/// Only the presets that change are touched, so an unchanged list isn't saved again.
pub fn refresh(s: &mut Session) {
let luts = s.lut_profiles.clone();
let stale: Vec<usize> = s
.presets
.iter()
.enumerate()
.filter(|(_, p)| !p.builtin)
.filter(|(_, p)| {
let mut settings = p.settings.clone();
apply(&mut settings, &luts).is_some_and(|(_, changed)| changed)
})
.map(|(i, _)| i)
.collect();
if stale.is_empty() {
return;
}
let presets = &mut *s.presets;
for i in stale {
if let Some(p) = presets.get_mut(i) {
apply(&mut p.settings, &luts);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lut(id: &str, name: &str, group: &str) -> LutProfile {
LutProfile { id: id.into(), name: name.into(), group: group.into(), file: String::new() }
}
#[test]
fn resolves_by_name_with_the_group_breaking_ties() {
let luts = [lut("lut:a", "Kodak Portra 400", "Films A"), lut("lut:b", "Kodak portra 400", "RNI Films")];
let look = json!({"name": "Kodak Portra 400", "group": "RNI Films", "amount": 100.0});
assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:b".into()));
let look = json!({"name": "kodak portra 400", "amount": 100.0});
assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:a".into()), "first by name without a group");
assert_eq!(resolve(&json!({"name": "Fuji 400H"}), &luts), Resolved::NotImported);
}
#[test]
fn other_app_profiles_map_to_ours_or_are_reported() {
assert_eq!(resolve(&json!({"name": "Adobe Monochrome"}), &[]), Resolved::BuiltIn("lc.mono".into()));
for n in ["Adobe Standard", "Camera Faithful", "Artistic 01", "B&W 12", "Vintage 3"] {
assert_eq!(resolve(&json!({"name": n}), &[]), Resolved::OtherAppBuiltIn, "{n}");
}
assert_eq!(resolve(&json!({"name": "Vintage Film"}), &[]), Resolved::NotImported);
// an imported profile wins over the mapping
assert_eq!(resolve(&json!({"name": "Adobe Vivid"}), &[lut("lut:v", "Adobe Vivid", "Mine")]), Resolved::Imported("lut:v".into()));
}
#[test]
fn apply_sets_and_clears_the_profile() {
let mut s = json!({"profile": {"look": {"name": "mb1 profile", "amount": 80.0}}, "light": {"highlights": -6.0}});
assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, false)));
let luts = [lut("lut:mb1", "mb1 profile", "Imported")];
assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), true)));
assert_eq!((s["profile"]["id"].clone(), s["profile"]["amount"].clone()), (json!("lut:mb1"), json!(80.0)));
assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), false)), "already resolved");
assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, true)), "profile deleted");
assert!(s["profile"].get("id").is_none());
assert_eq!(apply(&mut json!({"light": {}}), &luts), None);
}
#[test]
fn look_ref_reads_name_group_uuid_and_amount() {
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:Exposure2012="0">
<crs:Look><rdf:Description crs:Name="mb1 profile" crs:Amount="0.75" crs:UUID="0A1B2C">
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Erich McVey B&amp;W Profiles</rdf:li></rdf:Alt></crs:Group>
</rdf:Description></crs:Look>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let props = lightcraft_meta::parse_xmp(x).unwrap().properties;
assert_eq!(look_ref(&props), Some(json!({"name": "mb1 profile", "amount": 75.0, "group": "Erich McVey B&W Profiles", "uuid": "0a1b2c"})));
assert_eq!(look_ref(&Props::new()), None);
}
}
+32
View File
@@ -1583,3 +1583,35 @@ fn rotate_carries_the_crop_with_the_image() {
assert_eq!(c.geometry.rect, left_half);
assert_eq!(c.aspect, Some((2, 3)));
}
/// A call that names its photos with a malformed `id` or `ids` (a string, a mixed list) is refused and changes
/// nothing; it used to act on the selection, or on the valid part of the list (issue #1506). Leaving the targets
/// out still means the selection.
#[test]
fn malformed_photo_targets_are_refused_not_widened() {
use lightcraft_catalog::PhotoId;
let mut s = Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
s.execute("library.select", &json!({"ids": [ids[0], ids[1]]})).unwrap();
let rating = |s: &Session, i: u64| s.catalog.photo(PhotoId(i)).unwrap().rating;
let before: Vec<u8> = ids.iter().map(|i| rating(&s, *i)).collect();
for bad in [
json!({"rating": 5, "id": "5"}),
json!({"rating": 5, "ids": [ids[2], "x"]}),
json!({"rating": 5, "ids": ids[2].to_string()}),
json!({"rating": 5, "id": -1}),
] {
let err = s.execute("photo.rate", &bad).unwrap_err();
assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}");
assert_eq!(ids.iter().map(|i| rating(&s, *i)).collect::<Vec<_>>(), before, "{bad} changed a rating");
}
let snapshot = s.catalog.to_snapshot();
assert!(s.execute("photo.deletePermanently", &json!({"id": "5"})).is_err());
assert_eq!(s.catalog.to_snapshot(), snapshot, "nothing deleted");
// no targets, or a null one: the selection
s.execute("photo.rate", &json!({"rating": 4, "id": null})).unwrap();
assert_eq!((rating(&s, ids[0]), rating(&s, ids[1]), rating(&s, ids[2])), (4, 4, before[2]));
// a preset's id is not a photo target
let preset = s.presets.first().unwrap().id.clone();
s.execute("preset.apply", &json!({"id": preset})).unwrap();
}
+24
View File
@@ -579,6 +579,30 @@ fn remove_location_holds_for_original_and_dng_exports() {
let _ = std::fs::remove_dir_all(&dir);
}
/// `metadata: "NONE"` as `app.export` sends it: the JPEG carries no location or camera data
/// (it used to be read as `all` and carry both).
#[test]
fn metadata_none_in_any_case_exports_no_metadata() {
let mut s = Session::with_demo();
let id = s.active().unwrap();
s.execute("photo.setMeta", &json!({"ids": [id.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap();
let export = |s: &mut Session, extra: serde_json::Value| {
let mut p = json!({"format": "jpeg", "longEdge": 64});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
lightcraft_meta::extract(&export_photo(s, id, &ExportOptions::from_params(&p).unwrap(), 1).unwrap().bytes)
};
let all = export(&mut s, json!({}));
assert!(all.gps.is_some() && all.city.is_some(), "the default keeps everything: {all:?}");
for v in ["none", "NONE", "None"] {
let m = export(&mut s, json!({"metadata": v}));
assert!(m.gps.is_none() && m.city.is_none() && m.model.is_none(), "{v}: {m:?}");
}
let m = export(&mut s, json!({"metadata": "ALL", "removeLocation": true}));
assert!(m.gps.is_none() && m.city.is_none(), "{m:?}");
}
/// An original raw whose own metadata holds GPS is refused under "Remove location info".
#[test]
fn remove_location_refuses_a_raw_that_embeds_gps() {
+1 -1
View File
@@ -179,7 +179,7 @@ fn hostile_ids_unsupported_models_and_odd_folders_are_handled() {
/// Settings > Faces downloads; nothing is committed). Without it those tests say so and pass.
fn demo_with_yunet() -> Option<Session> {
let Some(file) = std::env::var_os("LC_YUNET_MODEL") else {
eprintln!("LC_YUNET_MODEL is not set: skipping");
lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set");
return None;
};
let dir = temp("yunet");
+1 -1
View File
@@ -34,7 +34,7 @@ pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) {
/// committed) and says whether it did: the parts of a test that need to find faces run only then.
pub(crate) fn install_yunet(s: &mut Session) -> bool {
let Some(file) = std::env::var_os("LC_YUNET_MODEL") else {
eprintln!("LC_YUNET_MODEL is not set: skipping the face-finding part");
lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set, so the face-finding part is not checked");
return false;
};
let r = s.execute("faces.models.install", &json!({"path": file.to_string_lossy(), "acknowledged": true}));
+68
View File
@@ -1680,3 +1680,71 @@ fn raw_sidecar_rating_and_crop_survive_naming_and_encoding_variants() {
let _ = std::fs::remove_dir_all(&src);
}
}
/// Issue #1001: a Lightroom preset that names a profile applies it once the user has imported a
/// profile of that name (before or after the preset), maps the basic Adobe profiles to ours, and
/// reports the ones it can't use.
#[test]
fn presets_apply_the_imported_profile_they_name() {
let src = temp_dir("lookref");
let preset = |file: &str, name: &str, look: &str| {
let x = format!(
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:PresetType="Normal" crs:Name="{name}" crs:UUID="{file}" crs:Exposure2012="0" crs:GrainAmount="20">
<crs:Look><rdf:Description crs:Name="{look}" crs:Amount="0.6" crs:UUID="1234ABCD">
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Film Pack</rdf:li></rdf:Alt></crs:Group>
</rdf:Description></crs:Look>
</rdf:Description></rdf:RDF></x:xmpmeta>"#
);
std::fs::write(src.join(format!("{file}.xmp")), x).unwrap();
};
preset("a1", "Portra Soft", "Portra 400");
preset("a2", "Mono", "Adobe Monochrome");
preset("a3", "Artsy", "Artistic 01");
let mut s = Session::with_demo();
let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let by_name = |r: &serde_json::Value, n: &str| r["imported"].as_array().unwrap().iter().find(|i| i["name"] == n).cloned().unwrap();
assert_eq!(by_name(&r, "Portra Soft")["unmapped"], json!(["Profile “Portra 400” (not imported yet)"]), "{r}");
assert_eq!(by_name(&r, "Mono")["profile"], json!({"name": "Adobe Monochrome", "id": "lc.mono"}));
assert_eq!(by_name(&r, "Artsy")["unmapped"], json!(["Profile “Artistic 01” (Lightroom's own)"]));
let photo = s.catalog.photos().next().unwrap().id;
s.execute("library.select", &json!({"ids": [photo.0]})).unwrap();
let pid = |s: &Session, n: &str| s.presets.iter().find(|p| p.name == n).unwrap().id.clone();
let apply = |s: &mut Session, n: &str| {
let id = pid(s, n);
s.execute("preset.apply", &json!({"id": id})).unwrap();
s.develop_of(photo).unwrap()
};
// not imported yet: only grain comes across, the photo keeps its profile
let d = apply(&mut s, "Portra Soft");
assert_eq!((d.profile.id.as_str(), d.grain.amount), ("lc.color", 20.0));
// the basic Adobe profiles are ours
assert_eq!(apply(&mut s, "Mono").profile.id, "lc.mono");
s.execute("develop.profile", &json!({"id": "lc.color", "amount": 100})).unwrap();
assert_eq!(apply(&mut s, "Artsy").profile.id, "lc.color", "Lightroom's own creative profiles aren't ours");
// importing the profile afterwards makes the preset use it, at the preset's amount
let mut cube = String::from("TITLE \"Portra 400\"\nLUT_3D_SIZE 2\n");
for b in 0..2 {
for g in 0..2 {
for r in 0..2 {
cube.push_str(&format!("{r} {g} {b}\n"));
}
}
}
std::fs::create_dir_all(src.join("profiles/Film Pack")).unwrap();
std::fs::write(src.join("profiles/Film Pack/portra.cube"), &cube).unwrap();
let r = s.execute("profile.import", &json!({"paths": [src.join("profiles").to_string_lossy()]})).unwrap();
let lut = r["imported"][0]["id"].as_str().unwrap().to_string();
let d = apply(&mut s, "Portra Soft");
assert_eq!((d.profile.id.as_str(), d.profile.amount, d.grain.amount), (lut.as_str(), 60.0, 20.0));
// a preset imported after the profile resolves straight away
preset("a4", "Portra Hard", "portra 400");
let r = s.execute("preset.import", &json!({"paths": [src.join("a4.xmp").to_string_lossy()]})).unwrap();
assert_eq!((r["imported"][0]["profile"]["id"].as_str(), r["imported"][0]["unmapped"].clone()), (Some(lut.as_str()), json!([])), "{r}");
// deleting the profile leaves the photo's own profile alone again
s.execute("profile.deleteImported", &json!({"id": lut})).unwrap();
s.execute("develop.profile", &json!({"id": "lc.vivid", "amount": 100})).unwrap();
assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid");
let _ = std::fs::remove_dir_all(&src);
}
+15
View File
@@ -440,6 +440,21 @@ fn scale_100k_library_queries() {
m("undo", &mut || {
s.execute("edit.undo", &serde_json::json!({})).unwrap();
});
// a 20k-photo album shown in its own order (capture-date sort, no filter)
let big = s.execute("album.create", &serde_json::json!({"name": "Big"})).unwrap()["id"].as_u64().unwrap();
let ids: Vec<u64> = (0..n).rev().step_by(5).map(|i| i + 1).collect();
m("add 20k to an album", &mut || {
s.execute("album.addPhotos", &serde_json::json!({"id": big, "ids": ids})).unwrap();
});
m("album of 20k (album order)", &mut || {
s.execute("library.source", &serde_json::json!({"kind": "album", "id": big})).unwrap();
let _ = s.visible_cloned();
});
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
let half: Vec<u64> = ids.iter().copied().step_by(2).collect();
m("remove 10k from the album", &mut || {
s.execute("album.removePhotos", &serde_json::json!({"id": big, "ids": half})).unwrap();
});
worst.sort_by(|a, b| b.1.total_cmp(&a.1));
eprintln!("slowest: {:?}", &worst[..3]);
}
+343
View File
@@ -0,0 +1,343 @@
//! The `print.*` commands: state, cells, templates and the PDF.
use lightcraft_catalog::MemStore;
use serde_json::{Value, json};
use crate::Session;
use crate::library::LibraryStores;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn fails(s: &mut Session, id: &str, p: Value) -> String {
s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string()
}
fn package(s: &mut Session) {
run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}}));
}
#[test]
fn get_set_and_reset() {
let mut s = Session::with_demo();
let g = run(&mut s, "print.get", json!({}));
assert_eq!(g["settings"]["layout"]["style"], "singleImage");
assert_eq!(g["pageWidth"], 8.5);
assert_eq!(g["printable"]["x"], 0.25);
assert_eq!(g["settings"]["job"]["ppi"], 240.0);
assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4"));
assert_eq!(g["gridCells"].as_array().unwrap().len(), 1);
let g = run(
&mut s,
"print.set",
json!({"layout": {"style": "contactSheet", "grid": {"columns": 2, "rows": 3}, "page": {"paper": "a4", "orientation": "landscape", "margins": {"left": 1.0}}}, "image": {"zoomToFill": true}, "job": {"ppi": 300.0}}),
);
assert_eq!(g["settings"]["layout"]["page"]["margins"]["left"], 1.0);
assert_eq!(g["settings"]["layout"]["page"]["margins"]["top"], 0.25, "a patch merges");
assert_eq!(g["gridCells"].as_array().unwrap().len(), 6);
assert!((g["pageWidth"].as_f64().unwrap() - 11.693).abs() < 1e-3);
assert_eq!(g["settings"]["image"]["zoomToFill"], true);
// the page count follows the photos: 6 cells per page
let g = run(&mut s, "print.get", json!({"ids": vec![1; 7]}));
assert_eq!(g["pageCount"], 2);
let g = run(&mut s, "print.reset", json!({}));
assert_eq!(g["settings"]["job"]["ppi"], 240.0);
}
#[test]
fn bad_settings_are_refused_and_change_nothing() {
let mut s = Session::with_demo();
let before = run(&mut s, "print.get", json!({}));
for p in [
json!({"typo": 1}),
json!({"layout": {"typo": 1}}),
json!({"layout": {"style": "poster"}}),
json!({"layout": {"cells": []}}),
json!({"layout": {"pages": 3}}),
json!({"layout": {"page": {"paper": "poster"}}}),
json!({"layout": {"page": {"paper": "custom", "customWidth": -1}}}),
json!({"layout": {"page": {"margins": {"top": 1e9}}}}),
json!({"layout": {"page": {"margins": {"top": 6.0, "bottom": 5.0}}}}),
json!({"layout": {"grid": {"columns": 0}}}),
json!({"layout": {"grid": {"columns": 9999}}}),
json!({"layout": {"spacing": -1}}),
json!({"image": {"borderWidth": 99}}),
json!({"image": {"strokeWidthPt": -2}}),
json!({"image": {"borderColor": [1, 2]}}),
json!({"image": {"zoomToFill": "yes"}}),
json!({"guides": {"gridSize": 0}}),
json!({"guides": {"units": "furlong"}}),
json!({"job": {"ppi": 1e30}}),
json!({"job": {"ppi": 10}}),
json!({"job": null}),
] {
fails(&mut s, "print.set", p);
}
assert_eq!(run(&mut s, "print.get", json!({})), before);
assert!(s.execute("print.set", &json!([1])).is_err());
}
#[test]
fn cells_are_commands() {
let mut s = Session::with_demo();
// grid styles have no cells
fails(&mut s, "print.cell.add", json!({"width": 2, "height": 2}));
package(&mut s);
let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap();
let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0, "x": 5.0, "y": 5.0}))["id"].as_u64().unwrap();
let g = run(&mut s, "print.get", json!({}));
assert_eq!(g["settings"]["layout"]["cells"].as_array().unwrap().len(), 2);
assert_eq!(g["overlaps"], false);
// move, clamped inside the margins
let r = run(&mut s, "print.cell.move", json!({"id": a, "x": 99.0, "y": 0.0}));
assert_eq!(r["rect"]["x"], 8.25 - 3.0);
assert_eq!(r["rect"]["y"], 0.25);
// resize keeps what is not given
let r = run(&mut s, "print.cell.resize", json!({"id": b, "width": 4.0}));
assert_eq!((r["rect"]["w"].as_f64().unwrap(), r["rect"]["h"].as_f64().unwrap()), (4.0, 2.0));
// snapping to the other cell's edge
let ra = run(&mut s, "print.get", json!({}))["settings"]["layout"]["cells"][0]["rect"].clone();
let left = ra["x"].as_f64().unwrap();
let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true}));
assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.0)).abs() < 1e-9, "{r}");
let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0}));
assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "no snap unless asked: {r}");
run(&mut s, "print.set", json!({"guides": {"snap": "off"}}));
let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true}));
assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "snap off");
// stacking
let r = run(&mut s, "print.cell.stack", json!({"id": a, "order": "toFront"}));
assert_eq!(r["order"], json!([b, a]));
fails(&mut s, "print.cell.stack", json!({"id": a, "order": "sideways"}));
// photos and pages
let photo = s.catalog.photos().next().unwrap().id.0;
run(&mut s, "print.cell.photo", json!({"id": a, "photo": photo}));
fails(&mut s, "print.cell.photo", json!({"id": a, "photo": 999_999}));
run(&mut s, "print.layout.newPage", json!({}));
run(&mut s, "print.cell.page", json!({"id": b, "page": 1}));
fails(&mut s, "print.cell.page", json!({"id": b, "page": 7}));
let g = run(&mut s, "print.get", json!({}));
assert_eq!(g["pageCount"], 2);
// delete, clear
run(&mut s, "print.cell.delete", json!({"id": b}));
fails(&mut s, "print.cell.delete", json!({"id": b}));
fails(&mut s, "print.cell.move", json!({"id": 99, "x": 0, "y": 0}));
fails(&mut s, "print.cell.move", json!({"x": 0, "y": 0}));
run(&mut s, "print.layout.clear", json!({}));
let g = run(&mut s, "print.get", json!({}));
assert_eq!((g["settings"]["layout"]["cells"].as_array().unwrap().len(), g["pageCount"].as_u64()), (0, Some(1)));
}
#[test]
fn hostile_cell_numbers_are_refused() {
let mut s = Session::with_demo();
package(&mut s);
for (w, h) in [(0.0, 2.0), (-1.0, 2.0), (1e12, 1e12), (2.0, 1e-9), (50.0, 50.0)] {
fails(&mut s, "print.cell.add", json!({"width": w, "height": h}));
}
fails(&mut s, "print.cell.add", json!({"width": "wide", "height": 2}));
fails(&mut s, "print.cell.add", json!({"width": 2}));
let id = run(&mut s, "print.cell.add", json!({"width": 2, "height": 2}))["id"].as_u64().unwrap();
fails(&mut s, "print.cell.move", json!({"id": id, "x": "left", "y": 0}));
fails(&mut s, "print.cell.resize", json!({"id": id, "width": -4}));
fails(&mut s, "print.cell.resize", json!({"id": id, "width": "x"}));
fails(&mut s, "print.cell.move", json!({"id": id, "x": 1, "y": 1, "snap": "yes"}));
fails(&mut s, "print.cell.move", json!({"id": u64::MAX, "x": 1, "y": 1}));
let r = run(&mut s, "print.cell.move", json!({"id": id, "x": -1e300, "y": 1e300}));
assert_eq!(r["rect"]["x"], 0.25);
}
#[test]
fn auto_layout_fills_pages_without_overlap() {
let mut s = Session::with_demo();
run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}}));
for (w, h) in [(4.0, 6.0), (4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (8.0, 10.0)] {
run(&mut s, "print.cell.add", json!({"width": w, "height": h}));
}
let r = run(&mut s, "print.layout.autoLayout", json!({}));
assert!(r["pages"].as_u64().unwrap() >= 2);
let g = run(&mut s, "print.get", json!({}));
assert_eq!(g["overlaps"], false);
let again = run(&mut s, "print.layout.autoLayout", json!({}));
assert_eq!(again["cells"], r["cells"], "deterministic");
// an oversize cell that arrived by a paper change is pulled in, not left outside the page
run(&mut s, "print.set", json!({"layout": {"page": {"paper": "4x6"}}}));
let g = run(&mut s, "print.get", json!({}));
for c in g["settings"]["layout"]["cells"].as_array().unwrap() {
assert!(c["rect"]["x"].as_f64().unwrap() + c["rect"]["w"].as_f64().unwrap() <= 4.0 - 0.25 + 1e-9);
}
}
/// The first page's MediaBox width and height in points.
fn media_box(pdf: &[u8]) -> (f64, f64) {
let text = String::from_utf8_lossy(pdf);
let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len();
let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
(nums[2], nums[3])
}
fn temp_pdf(name: &str) -> String {
std::env::temp_dir().join(format!("lightcraft-print-{name}-{}.pdf", std::process::id())).to_string_lossy().to_string()
}
#[test]
fn export_pdf_prints_the_current_settings() {
let mut s = Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
run(
&mut s,
"print.set",
json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 2}, "page": {"paper": "a5"}}, "job": {"ppi": 100.0}}),
);
let path = temp_pdf("export");
let r = run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
assert_eq!((r["pages"].as_u64(), r["photos"].as_u64()), (Some(2), Some(3)));
let bytes = std::fs::read(&path).unwrap();
assert_eq!(bytes.len() as u64, r["bytes"].as_u64().unwrap());
let text = String::from_utf8_lossy(&bytes);
assert!(text.starts_with("%PDF-") && text.contains("/Count 2"));
// A5 is 148 x 210 mm = 419.53 x 595.28 pt, rounded to whole pixels at 100 ppi (583 x 827)
let (w, h) = media_box(&bytes);
assert!((w - 419.53).abs() < 1.0 && (h - 595.28).abs() < 1.0, "A5, got {w} x {h} pt");
// the row in the activity stack is gone when the print is done
assert!(s.activity.list().is_empty());
fails(&mut s, "print.exportPdf", json!({}));
fails(&mut s, "print.exportPdf", json!({"path": path, "typo": 1}));
fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ["x"]}));
fails(&mut s, "print.exportPdf", json!({"path": path, "ids": [999_999]}));
// a package with no cells has nothing to print
run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}}));
fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
}
#[test]
fn export_pdf_with_a_printer_profile() {
let mut s = Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(1).map(|p| p.id.0).collect();
let dir = std::env::temp_dir().join(format!("lightcraft-print-icc-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let icc = dir.join("Fine Art Matte.icc");
std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap();
let color = json!({"profile": {"kind": "icc", "path": icc}, "intent": "relative", "sharpen": "high", "media": "matte", "brightness": 10});
run(
&mut s,
"print.set",
json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0, "color": color}}),
);
assert_eq!(run(&mut s, "print.get", json!({}))["settings"]["job"]["color"]["media"], "matte");
let path = temp_pdf("icc");
run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
let text = String::from_utf8_lossy(&std::fs::read(&path).unwrap()).into_owned();
assert!(text.contains("/OutputIntents") && text.contains("/ICCBased") && text.contains("/BitsPerComponent 16"));
// out-of-range adjustments are refused when set; a missing profile fails before rendering
fails(&mut s, "print.set", json!({"job": {"color": {"contrast": 150}}}));
run(&mut s, "print.set", json!({"job": {"color": {"profile": {"kind": "icc", "path": dir.join("gone.icc")}}}}));
let err = fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids}));
assert!(err.contains("gone.icc"), "{err}");
assert!(s.activity.list().is_empty(), "nothing started");
}
#[test]
fn custom_package_prints_each_cells_photo() {
let mut s = Session::with_demo();
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
run(&mut s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}}));
let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap();
let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap();
run(&mut s, "print.cell.photo", json!({"id": a, "photo": ids[0]}));
run(&mut s, "print.cell.photo", json!({"id": b, "photo": ids[1]}));
let r = run(&mut s, "print.exportPdf", json!({"path": temp_pdf("custom"), "ids": [ids[0]]}));
assert_eq!(r["pages"], 1);
}
#[test]
fn templates_save_apply_delete_and_persist() {
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 mut s = open(&files);
run(
&mut s,
"print.set",
json!({"layout": {"style": "contactSheet", "grid": {"columns": 4, "rows": 5}}, "page": {"background": [10, 20, 30]}, "guides": {"gridSize": 1.0}}),
);
let r = run(&mut s, "print.template.save", json!({"name": "Proofs"}));
assert_eq!(r["templates"][0]["name"], "Proofs");
assert!(r["templates"][0]["settings"].get("guides").is_none());
run(&mut s, "print.template.save", json!({"name": "proofs"}));
assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 1, "the same name in another case replaces");
run(&mut s, "print.reset", json!({}));
run(&mut s, "print.set", json!({"guides": {"gridSize": 0.5}}));
let g = run(&mut s, "print.template.apply", json!({"name": "PROOFS"}));
assert_eq!(g["settings"]["layout"]["grid"]["columns"], 4);
assert_eq!(g["settings"]["page"]["background"], json!([10, 20, 30]));
assert_eq!(g["settings"]["guides"]["gridSize"], 0.5, "guides stay as they were");
// it survives reopening the library
let mut again = open(&files);
assert_eq!(run(&mut again, "print.template.list", json!({}))["templates"][0]["name"], "proofs");
run(&mut again, "print.template.apply", json!({"name": "Proofs"}));
fails(&mut s, "print.template.save", json!({"name": " "}));
fails(&mut s, "print.template.save", json!({}));
fails(&mut s, "print.template.apply", json!({"name": "Nope"}));
fails(&mut s, "print.template.delete", json!({"name": "Nope"}));
run(&mut s, "print.template.delete", json!({"name": "proofs"}));
assert!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty());
assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty());
}
#[test]
fn a_hostile_template_in_the_preferences_does_not_break_the_library() {
let files = MemStore::new();
let hostile = json!({"printTemplates": [
{"name": "Bad", "settings": {"layout": {"pages": 0}, "extra": true}},
{"name": "Huge", "settings": {"job": {"ppi": 1e300}}},
{"name": "Fine", "settings": {"layout": {"style": "contactSheet"}}}
], "cacheMb": 123});
files.set("prefs.json", serde_json::to_vec(&hostile).unwrap());
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();
assert!(s.take_library_warnings().is_empty(), "the preferences are not damaged");
assert_eq!(s.cache_mb, 123);
assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 3);
fails(&mut s, "print.template.apply", json!({"name": "Bad"}));
fails(&mut s, "print.template.apply", json!({"name": "Huge"}));
run(&mut s, "print.template.apply", json!({"name": "Fine"}));
}
#[test]
fn every_print_command_is_registered_with_a_description() {
for id in [
"print.get",
"print.set",
"print.reset",
"print.cell.add",
"print.cell.move",
"print.cell.resize",
"print.cell.delete",
"print.cell.stack",
"print.cell.photo",
"print.cell.page",
"print.layout.autoLayout",
"print.layout.newPage",
"print.layout.clear",
"print.exportPdf",
"print.template.list",
"print.template.save",
"print.template.apply",
"print.template.delete",
] {
let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing"));
assert!(c.params.len() > 3, "{id}");
}
}
+5 -3
View File
@@ -584,8 +584,10 @@ pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec<u16>, xm
},
wb_multipliers: None,
linearized: false,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap()
}
@@ -666,7 +668,7 @@ fn convert_raw_to_dng() {
let r = s.execute("photo.convertToDng", &json!({})).unwrap();
assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1));
if !corpus.exists() {
eprintln!("skip: {} absent", corpus.display());
lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display());
return;
}
let nef = dir.join("shot.nef");
@@ -928,7 +930,7 @@ fn dng_export_compression_choices() {
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/nef-nikon-d5100-uncompressed.nef");
if !corpus.exists() {
eprintln!("skip: {} absent", corpus.display());
lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display());
return;
}
let mut s = Session::new().with_fs();
@@ -973,7 +975,7 @@ fn dngs_of_raws_without_colour_matrices_carry_the_colour_they_open_with() {
std::fs::write(&path, &bytes).unwrap();
let mut opened = lightcraft_raw::decode(&bytes).unwrap();
assert!(!has_matrix(&opened.color));
let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None);
let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None, None);
assert_eq!((want.matrix_is_fallback, look.is_none()), (!measured, true), "{model}");
let mut s = Session::new().with_fs();
+1 -1
View File
@@ -13,7 +13,7 @@ use std::time::Instant;
#[ignore = "needs LC_PREVIEW_RAW pointing at a real raw file"]
fn a_preview_reads_only_the_header_and_the_preview() {
let Ok(path) = std::env::var("LC_PREVIEW_RAW") else {
eprintln!("skipped: set LC_PREVIEW_RAW=<path to a raw file>");
lightcraft_testkit::skip!(Input, "set LC_PREVIEW_RAW=<path to a raw file>");
return;
};
let file_len = std::fs::metadata(&path).expect("the file exists").len();