1use std::path::Path;
4
5use kallo_core::contact::{ContactSpec, LADDER_MINUTES, SheetGeometry, Strips, print, printable};
6use kallo_core::{DevelopmentParams, FilmStock, Metering};
7use kallo_io::{Image, resize_to_width, save};
8
9use crate::error::Error;
10use crate::render::{Exposure, ensure_parent};
11use crate::selection::Selection;
12
13#[derive(Debug, Clone)]
15pub struct ContactOptions {
16 pub ev: Exposure,
18 pub print_ev: f64,
20 pub dpi: f64,
22 pub grain: bool,
24 pub halation: bool,
26 pub couplers: bool,
28 pub edge_codes: bool,
30 pub record: Option<std::path::PathBuf>,
32}
33
34impl Default for ContactOptions {
35 fn default() -> Self {
36 Self {
37 ev: Exposure::Auto,
38 print_ev: 0.0,
39 dpi: 600.0,
40 grain: false,
41 halation: false,
42 couplers: false,
43 edge_codes: true,
44 record: None,
45 }
46 }
47}
48
49pub struct ContactOutcome {
51 pub geometry: SheetGeometry,
53 pub exposure_ev: f64,
55 pub metering: Option<Metering>,
57}
58
59pub fn contact(
66 source: &Image,
67 film: Option<&'static FilmStock>,
68 selection: &Selection,
69 times: &[f64],
70 options: &ContactOptions,
71 output: &Path,
72) -> Result<ContactOutcome, Error> {
73 let film = film.unwrap_or(selection.film);
74 if !printable(film) {
75 return Err(Error::Contact {
76 message: format!(
77 "{} is a reversal — it is its own picture, and there is no contact proof to make",
78 film.short
79 ),
80 });
81 }
82 if times.is_empty() {
83 return Err(Error::Contact {
84 message: "a proof needs at least one development".to_owned(),
85 });
86 }
87 if let Some(bad) = times
88 .iter()
89 .copied()
90 .find(|time| !(time.is_finite() && *time > 0.0))
91 {
92 return Err(Error::InvalidNumber {
93 flag: "--dev",
94 value: bad,
95 expected: "a positive number of minutes",
96 });
97 }
98 if times.len() > 36 {
99 return Err(Error::Contact {
100 message: format!(
101 "{} strips is more than one sheet holds; cut the ladder to thirty-six or fewer",
102 times.len()
103 ),
104 });
105 }
106 if !options.dpi.is_finite() || options.dpi <= 0.0 {
107 return Err(Error::InvalidNumber {
108 flag: "--dpi",
109 value: options.dpi,
110 expected: "a positive number of dots per inch",
111 });
112 }
113
114 let temp = selection.development.temp_c;
115 let stood = source.height > source.width;
122 let geometry = SheetGeometry::new(
123 times.len(),
124 options.dpi,
125 if stood {
126 Strips::Vertical
127 } else {
128 Strips::Horizontal
129 },
130 );
131 let cropped = if stood {
132 crop(source, 1.5f64.recip())
133 } else {
134 crop(source, 1.5)
135 };
136 let frame = sample(&cropped, geometry.frame.0, geometry.frame.1);
140 drop(cropped);
141
142 let (exposure_ev, metering) = match options.ev {
143 Exposure::Stops(ev) => (ev, None),
144 Exposure::Auto => {
145 let metered = Metering::of(&frame.pixels);
146 (metered.ev, Some(metered))
147 }
148 };
149
150 let developments: Vec<DevelopmentParams> = times
151 .iter()
152 .map(|minutes| DevelopmentParams {
153 time_min: *minutes,
154 temp_c: temp,
155 reference_time_min: selection.development.reference_time_min,
156 reference_temp_c: selection.development.reference_temp_c,
157 ..DevelopmentParams::default()
158 })
159 .collect();
160 let spec = ContactSpec {
161 film,
162 paper: selection.paper,
163 developments,
164 exposure_ev,
165 print_ev: options.print_ev,
166 spectral: selection.spectral,
167 emulsion: true,
170 grain: options.grain,
171 halation: options.halation,
172 couplers: options.couplers,
173 edge_codes: options.edge_codes,
174 };
175
176 let kallo_core::Contact {
177 geometry: sheet,
178 pixels,
179 } = print(&frame.pixels, &geometry, &spec);
180
181 ensure_parent(output)?;
182 save(
183 output,
184 &Image {
185 width: sheet.width as u32,
186 height: sheet.height as u32,
187 pixels,
188 },
189 )
190 .map_err(|source| Error::Write {
191 path: output.to_path_buf(),
192 source,
193 })?;
194
195 if let Some(path) = &options.record {
196 write_record(path, &sheet)?;
197 }
198
199 Ok(ContactOutcome {
200 geometry: sheet,
201 exposure_ev,
202 metering,
203 })
204}
205
206#[must_use]
208pub fn ladder() -> &'static [f64] {
209 &LADDER_MINUTES
210}
211
212fn write_record(path: &Path, sheet: &SheetGeometry) -> Result<(), Error> {
214 let frames: Vec<serde_json::Value> = sheet
215 .frames
216 .iter()
217 .map(|frame| {
218 serde_json::json!({
219 "number": frame.number,
220 "minutes": frame.minutes,
221 "x": frame.x,
222 "y": frame.y,
223 "width": frame.width,
224 "height": frame.height,
225 })
226 })
227 .collect();
228 let record = serde_json::json!({
229 "width": sheet.width,
230 "height": sheet.height,
231 "margin": sheet.margin,
232 "dpi": sheet.dpi,
233 "frames": frames,
234 });
235 let text = serde_json::to_string_pretty(&record).map_err(|error| Error::Contact {
236 message: format!("could not write the record: {error}"),
237 })?;
238 if let Some(parent) = path.parent() {
239 if !parent.as_os_str().is_empty() {
240 std::fs::create_dir_all(parent).map_err(|source| Error::CreateDir {
241 path: parent.to_path_buf(),
242 source,
243 })?;
244 }
245 }
246 std::fs::write(path, format!("{text}\n")).map_err(|error| Error::Contact {
247 message: format!("could not write {}: {error}", path.display()),
248 })
249}
250
251fn crop(source: &Image, aspect: f64) -> Image {
253 let width = source.width as usize;
254 let height = source.height as usize;
255 if width == 0 || height == 0 {
256 return source.clone();
257 }
258 let (cropped_w, cropped_h) = if width as f64 / height as f64 > aspect {
259 (((height as f64 * aspect).round() as usize).max(1), height)
260 } else {
261 (
262 width,
263 ((width as f64 / aspect).round() as usize)
264 .min(height)
265 .max(1),
266 )
267 };
268 let x0 = (width - cropped_w) / 2;
269 let y0 = (height - cropped_h) / 2;
270 let mut pixels = Vec::with_capacity(cropped_w * cropped_h);
271 for row in 0..cropped_h {
272 let start = (y0 + row) * width + x0;
273 pixels.extend_from_slice(&source.pixels[start..start + cropped_w]);
274 }
275 Image {
276 width: cropped_w as u32,
277 height: cropped_h as u32,
278 pixels,
279 }
280}
281
282fn sample(source: &Image, width: usize, height: usize) -> Image {
287 if width < source.width as usize {
288 let scaled = resize_to_width(source, width as u32);
289 return fit_rows(&scaled, width, height);
290 }
291 let mut pixels = Vec::with_capacity(width * height);
294 for row in 0..height {
295 let source_row = row * source.height as usize / height;
296 for column in 0..width {
297 let source_column = column * source.width as usize / width;
298 pixels.push(source.pixels[source_row * source.width as usize + source_column]);
299 }
300 }
301 Image {
302 width: width as u32,
303 height: height as u32,
304 pixels,
305 }
306}
307
308fn fit_rows(image: &Image, width: usize, height: usize) -> Image {
310 let mut pixels = Vec::with_capacity(width * height);
311 for row in 0..height {
312 let source_row = row.min(image.height as usize - 1);
313 let start = source_row * image.width as usize;
314 pixels.extend_from_slice(&image.pixels[start..start + width]);
315 }
316 Image {
317 width: width as u32,
318 height: height as u32,
319 pixels,
320 }
321}