use std::path::Path;
use kallo_core::contact::{ContactSpec, LADDER_MINUTES, SheetGeometry, Strips, print, printable};
use kallo_core::{DevelopmentParams, FilmStock, Metering};
use kallo_io::{Image, resize_to_width, save};
use crate::error::Error;
use crate::render::{Exposure, ensure_parent};
use crate::selection::Selection;
#[derive(Debug, Clone)]
pub struct ContactOptions {
pub ev: Exposure,
pub print_ev: f64,
pub dpi: f64,
pub grain: bool,
pub halation: bool,
pub couplers: bool,
pub edge_codes: bool,
pub record: Option<std::path::PathBuf>,
}
impl Default for ContactOptions {
fn default() -> Self {
Self {
ev: Exposure::Auto,
print_ev: 0.0,
dpi: 600.0,
grain: false,
halation: false,
couplers: false,
edge_codes: true,
record: None,
}
}
}
pub struct ContactOutcome {
pub geometry: SheetGeometry,
pub exposure_ev: f64,
pub metering: Option<Metering>,
}
pub fn contact(
source: &Image,
film: Option<&'static FilmStock>,
selection: &Selection,
times: &[f64],
options: &ContactOptions,
output: &Path,
) -> Result<ContactOutcome, Error> {
let film = film.unwrap_or(selection.film);
if !printable(film) {
return Err(Error::Contact {
message: format!(
"{} is a reversal — it is its own picture, and there is no contact proof to make",
film.short
),
});
}
if times.is_empty() {
return Err(Error::Contact {
message: "a proof needs at least one development".to_owned(),
});
}
if let Some(bad) = times
.iter()
.copied()
.find(|time| !(time.is_finite() && *time > 0.0))
{
return Err(Error::InvalidNumber {
flag: "--dev",
value: bad,
expected: "a positive number of minutes",
});
}
if times.len() > 36 {
return Err(Error::Contact {
message: format!(
"{} strips is more than one sheet holds; cut the ladder to thirty-six or fewer",
times.len()
),
});
}
if !options.dpi.is_finite() || options.dpi <= 0.0 {
return Err(Error::InvalidNumber {
flag: "--dpi",
value: options.dpi,
expected: "a positive number of dots per inch",
});
}
let temp = selection.development.temp_c;
let stood = source.height > source.width;
let geometry = SheetGeometry::new(
times.len(),
options.dpi,
if stood {
Strips::Vertical
} else {
Strips::Horizontal
},
);
let cropped = if stood {
crop(source, 1.5f64.recip())
} else {
crop(source, 1.5)
};
let frame = sample(&cropped, geometry.frame.0, geometry.frame.1);
drop(cropped);
let (exposure_ev, metering) = match options.ev {
Exposure::Stops(ev) => (ev, None),
Exposure::Auto => {
let metered = Metering::of(&frame.pixels);
(metered.ev, Some(metered))
}
};
let developments: Vec<DevelopmentParams> = times
.iter()
.map(|minutes| DevelopmentParams {
time_min: *minutes,
temp_c: temp,
reference_time_min: selection.development.reference_time_min,
reference_temp_c: selection.development.reference_temp_c,
..DevelopmentParams::default()
})
.collect();
let spec = ContactSpec {
film,
paper: selection.paper,
developments,
exposure_ev,
print_ev: options.print_ev,
spectral: selection.spectral,
emulsion: true,
grain: options.grain,
halation: options.halation,
couplers: options.couplers,
edge_codes: options.edge_codes,
};
let kallo_core::Contact {
geometry: sheet,
pixels,
} = print(&frame.pixels, &geometry, &spec);
ensure_parent(output)?;
save(
output,
&Image {
width: sheet.width as u32,
height: sheet.height as u32,
pixels,
},
)
.map_err(|source| Error::Write {
path: output.to_path_buf(),
source,
})?;
if let Some(path) = &options.record {
write_record(path, &sheet)?;
}
Ok(ContactOutcome {
geometry: sheet,
exposure_ev,
metering,
})
}
#[must_use]
pub fn ladder() -> &'static [f64] {
&LADDER_MINUTES
}
fn write_record(path: &Path, sheet: &SheetGeometry) -> Result<(), Error> {
let frames: Vec<serde_json::Value> = sheet
.frames
.iter()
.map(|frame| {
serde_json::json!({
"number": frame.number,
"minutes": frame.minutes,
"x": frame.x,
"y": frame.y,
"width": frame.width,
"height": frame.height,
})
})
.collect();
let record = serde_json::json!({
"width": sheet.width,
"height": sheet.height,
"margin": sheet.margin,
"dpi": sheet.dpi,
"frames": frames,
});
let text = serde_json::to_string_pretty(&record).map_err(|error| Error::Contact {
message: format!("could not write the record: {error}"),
})?;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|source| Error::CreateDir {
path: parent.to_path_buf(),
source,
})?;
}
}
std::fs::write(path, format!("{text}\n")).map_err(|error| Error::Contact {
message: format!("could not write {}: {error}", path.display()),
})
}
fn crop(source: &Image, aspect: f64) -> Image {
let width = source.width as usize;
let height = source.height as usize;
if width == 0 || height == 0 {
return source.clone();
}
let (cropped_w, cropped_h) = if width as f64 / height as f64 > aspect {
(((height as f64 * aspect).round() as usize).max(1), height)
} else {
(
width,
((width as f64 / aspect).round() as usize)
.min(height)
.max(1),
)
};
let x0 = (width - cropped_w) / 2;
let y0 = (height - cropped_h) / 2;
let mut pixels = Vec::with_capacity(cropped_w * cropped_h);
for row in 0..cropped_h {
let start = (y0 + row) * width + x0;
pixels.extend_from_slice(&source.pixels[start..start + cropped_w]);
}
Image {
width: cropped_w as u32,
height: cropped_h as u32,
pixels,
}
}
fn sample(source: &Image, width: usize, height: usize) -> Image {
if width < source.width as usize {
let scaled = resize_to_width(source, width as u32);
return fit_rows(&scaled, width, height);
}
let mut pixels = Vec::with_capacity(width * height);
for row in 0..height {
let source_row = row * source.height as usize / height;
for column in 0..width {
let source_column = column * source.width as usize / width;
pixels.push(source.pixels[source_row * source.width as usize + source_column]);
}
}
Image {
width: width as u32,
height: height as u32,
pixels,
}
}
fn fit_rows(image: &Image, width: usize, height: usize) -> Image {
let mut pixels = Vec::with_capacity(width * height);
for row in 0..height {
let source_row = row.min(image.height as usize - 1);
let start = source_row * image.width as usize;
pixels.extend_from_slice(&image.pixels[start..start + width]);
}
Image {
width: width as u32,
height: height as u32,
pixels,
}
}