Skip to main content

kallo_api/
contact.rs

1//! The proof: the whole ladder on one sheet, exposed once.
2
3use 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/// Everything a proof needs beyond the photograph and the stocks.
14#[derive(Debug, Clone)]
15pub struct ContactOptions {
16    /// Exposure offset, or metering.
17    pub ev: Exposure,
18    /// Extra print exposure in stops.
19    pub print_ev: f64,
20    /// Sheet resolution.
21    pub dpi: f64,
22    /// Add grain.
23    pub grain: bool,
24    /// Add halation.
25    pub halation: bool,
26    /// Add DIR couplers.
27    pub couplers: bool,
28    /// Stamp the edge codes.
29    pub edge_codes: bool,
30    /// Where to write the hit-testing record, if anywhere.
31    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
49/// What a proof printed.
50pub struct ContactOutcome {
51    /// The sheet: size, margins and frame rects.
52    pub geometry: SheetGeometry,
53    /// The exposure the sheet was printed at.
54    pub exposure_ev: f64,
55    /// What the meter read, if the exposure was metered.
56    pub metering: Option<Metering>,
57}
58
59/// Print the ladder on one sheet, and write it where asked.
60///
61/// The photograph is taken at its own size and sampled to the sheet's frame —
62/// not the bench's. A proof is of the film, so its grain has to come from the
63/// frame the film carries, which is why this does not accept a pre-shrunk
64/// image.
65pub 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    // A 35 mm frame is 3:2 whatever the file is, in whichever direction the
116    // camera was held: a contact proof of a 4:5 file would be a proof of a
117    // frame the camera never exposed. A frame made with the camera on its side
118    // is not turned on the film — the strip is laid the other way on the paper,
119    // which is how a photographer lays out a proof sheet, because the point of
120    // one is to look at the pictures.
121    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    // The sheet is sized first, because the frame has to be sampled at exactly
137    // that width: a sheet scaled afterwards would be a lie about the grain and
138    // about the size of the film.
139    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        // The film's blur is on by default in `render` too: a proof is of the
168        // film, and the film softens what it carries.
169        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/// The default five-step ladder.
207#[must_use]
208pub fn ladder() -> &'static [f64] {
209    &LADDER_MINUTES
210}
211
212/// Write what a viewer needs to hit-test the sheet: the frame rects, in pixels.
213fn 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
251/// Centre-crop to an aspect ratio, because a frame is 3:2 whatever the file is.
252fn 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
282/// Sample the frame at exactly the sheet's frame size.
283///
284/// Everything after this reads the photograph at the size the film sees it, so
285/// the resize happens before the model and never after the print.
286fn 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    // A source smaller than the sheet's frame is upsampled rather than refused:
292    // a small file still has a proof, and a blocky one is honest about it.
293    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
308/// A row too many, or too few, after the aspect-preserving resize.
309fn 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}