mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
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:
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } } } }";
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>");
|
||||
}
|
||||
}
|
||||
@@ -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&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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user