Skip to main content

kallo_cli/
filmfile.rs

1//! Reading a `.kalofilm` file — the format in `data/films/FORMAT.md`.
2//!
3//! A film is data: a header that says where every number came from, the spectra
4//! as a contiguous block of little-endian f64, and a SHA-256 over the lot. This
5//! module turns that into a [`FilmStock`] the renderer already knows how to
6//! use, so a film can be added without recompiling.
7//!
8//! The tables are **leaked** rather than owned. `FilmStock` describes its
9//! spectra with `&'static` slices because every stock before this one was a
10//! compile-time constant, and threading an owner through `RenderParams`, the
11//! pipeline and both backends would be a large change to buy a few kilobytes
12//! that a process holds until it exits anyway. A caller that loads films in a
13//! loop should use [`read_owned`] instead, which returns the boxed stock.
14//!
15//! ## Why the digest is checked by hand
16//!
17//! The alternative is a dependency on a hash crate for sixty lines of a
18//! standard algorithm. This crate has one dependency (`clap`) and a `cargo deny`
19//! gate; the vectors below are FIPS 180-2's.
20
21use std::path::Path;
22
23use kallo_core::CharacteristicCurve;
24use kallo_core::Provenance;
25use kallo_core::dye::InhibitorLayer;
26use kallo_core::emulsion::{FilmProcess, FilmStock};
27
28use crate::stockfile::{Value, parse};
29
30/// Every table in version 1 is this many points on a 5 nm grid.
31const GRID_LEN: usize = 71;
32const MAGIC: &[u8; 8] = b"KALOFILM";
33
34/// What can go wrong reading a film file.
35#[derive(Debug)]
36pub enum FilmFileError {
37    /// The file could not be read.
38    Io(std::io::Error),
39    /// It is not a film file, or not this version of one.
40    Format(String),
41    /// The digest does not cover the bytes, so the file is truncated or altered.
42    Digest {
43        /// The digest stored in the file.
44        expected: String,
45        /// The digest of the bytes that are actually there.
46        found: String,
47    },
48    /// The header parses but does not describe a film this build understands.
49    Header(String),
50}
51
52impl std::fmt::Display for FilmFileError {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::Io(error) => write!(formatter, "{error}"),
56            Self::Format(message) => write!(formatter, "{message}"),
57            Self::Digest { expected, found } => write!(
58                formatter,
59                "digest mismatch: the file says {expected}, the bytes hash to {found}"
60            ),
61            Self::Header(message) => write!(formatter, "{message}"),
62        }
63    }
64}
65
66impl std::error::Error for FilmFileError {}
67
68impl From<std::io::Error> for FilmFileError {
69    fn from(error: std::io::Error) -> Self {
70        Self::Io(error)
71    }
72}
73
74/// Load a film, leaking its tables so the renderer can borrow them.
75pub fn read(path: impl AsRef<Path>) -> Result<&'static FilmStock, FilmFileError> {
76    read_owned(path).map(|stock| &*Box::leak(stock))
77}
78
79/// Load a film as an owned value, for a caller that loads many.
80pub fn read_owned(path: impl AsRef<Path>) -> Result<Box<FilmStock>, FilmFileError> {
81    let bytes = std::fs::read(path.as_ref())?;
82    if bytes.len() < 48 || &bytes[..8] != MAGIC {
83        return Err(FilmFileError::Format(
84            "not a kalofilm file (no KALOFILM magic)".to_owned(),
85        ));
86    }
87    let version = u32::from_le_bytes(bytes[8..12].try_into().expect("four bytes"));
88    if version != 1 {
89        return Err(FilmFileError::Format(format!(
90            "version {version}; this build reads version 1"
91        )));
92    }
93    let header_len = u32::from_le_bytes(bytes[12..16].try_into().expect("four bytes")) as usize;
94    let header_end = 16usize
95        .checked_add(header_len)
96        .filter(|end| *end + 32 <= bytes.len())
97        .ok_or_else(|| FilmFileError::Format("the header runs past the file".to_owned()))?;
98
99    let found = sha256(&bytes[..bytes.len() - 32]);
100    if found != bytes[bytes.len() - 32..] {
101        return Err(FilmFileError::Digest {
102            expected: hex(&bytes[bytes.len() - 32..]),
103            found: hex(&found),
104        });
105    }
106
107    let text = std::str::from_utf8(&bytes[16..header_end])
108        .map_err(|_| FilmFileError::Format("the header is not UTF-8".to_owned()))?;
109    let table = parse(text, Path::new("<film>"))
110        .map_err(|error| FilmFileError::Header(format!("the header does not parse: {error}")))?;
111    let tables = &bytes[header_end..bytes.len() - 32];
112
113    build(&table, tables)
114}
115
116fn string(
117    table: &std::collections::BTreeMap<String, Value>,
118    key: &str,
119) -> Result<String, FilmFileError> {
120    match table.get(key) {
121        Some(Value::String(value)) => Ok(value.clone()),
122        _ => Err(FilmFileError::Header(format!("`{key}` is missing"))),
123    }
124}
125
126fn number(
127    table: &std::collections::BTreeMap<String, Value>,
128    key: &str,
129) -> Result<f64, FilmFileError> {
130    match table.get(key) {
131        Some(Value::Number(value)) => Ok(*value),
132        _ => Err(FilmFileError::Header(format!("`{key}` is missing"))),
133    }
134}
135
136fn numbers(
137    table: &std::collections::BTreeMap<String, Value>,
138    key: &str,
139) -> Result<Vec<f64>, FilmFileError> {
140    match table.get(key) {
141        Some(Value::List(items)) => items
142            .iter()
143            .map(|item| match item {
144                Value::Number(value) => Ok(*value),
145                _ => Err(FilmFileError::Header(format!("`{key}` is not all numbers"))),
146            })
147            .collect(),
148        _ => Err(FilmFileError::Header(format!("`{key}` is missing"))),
149    }
150}
151
152fn grid(slice: &[f64]) -> Result<[f64; GRID_LEN], FilmFileError> {
153    slice
154        .try_into()
155        .map_err(|_| FilmFileError::Format(format!("a table is not {GRID_LEN} points")))
156}
157
158fn build(
159    table: &std::collections::BTreeMap<String, Value>,
160    tables: &[u8],
161) -> Result<Box<FilmStock>, FilmFileError> {
162    let schema = string(table, "schema")?;
163    if schema != "film/v1" {
164        return Err(FilmFileError::Format(format!("schema `{schema}`")));
165    }
166    let process = match string(table, "process")?.as_str() {
167        "negative" => FilmProcess::Negative,
168        "reversal" => FilmProcess::Reversal,
169        "monochrome" => FilmProcess::Monochrome,
170        other => return Err(FilmFileError::Header(format!("unknown process `{other}`"))),
171    };
172
173    let names = strings(table, "tables")?;
174    let mut at = 0usize;
175    let mut layers = None;
176    let mut dyes = None;
177    let mut mask = None;
178    let mut cl_layer = None;
179    for name in &names {
180        let rows = match name.as_str() {
181            "layers" | "dyes" => 3,
182            "mask" | "cl_sensitivity" => 1,
183            other => {
184                return Err(FilmFileError::Format(format!("unknown table `{other}`")));
185            }
186        };
187        let values = read_table(tables, &mut at, rows)?;
188        match name.as_str() {
189            "layers" => layers = Some(leak_three(values)),
190            "dyes" => dyes = Some(leak_three(values)),
191            "mask" => mask = Some(leak_one(&values[0])),
192            "cl_sensitivity" => {
193                cl_layer = Some(InhibitorLayer {
194                    sensitivity: leak_one(&values[0]),
195                });
196            }
197            _ => unreachable!("the name was matched above"),
198        }
199    }
200
201    let channel = |name: &str| -> Result<CharacteristicCurve, FilmFileError> {
202        let curve = |key: &str| number(table, &format!("curve.{name}.{key}"));
203        let base = CharacteristicCurve::new(
204            curve("d_min")?,
205            curve("d_max")?,
206            curve("gamma")?,
207            curve("log_e0")?,
208        )
209        .with_transition(curve("transition")?);
210        // A file written before the two corners could differ has no second
211        // rate, and its one number is still the right answer for both.
212        let toe = curve("toe_transition").unwrap_or(base.toe_transition);
213        let shoulder = curve("shoulder_transition").unwrap_or(base.shoulder_transition);
214        Ok(base.with_corners(toe, shoulder))
215    };
216    let sigma = numbers(table, "character.sigma_um")?;
217    // The header carries the sheet, and a file that cannot say where it came
218    // from is still a film — it is a film with nothing to cite, which the empty
219    // fields say better than a borrowed citation would.
220    let field = |key: &str| {
221        string(table, &format!("provenance.{key}"))
222            .map(leak_str)
223            .unwrap_or("")
224    };
225    let label = string(table, "label")?;
226    let short = string(table, "short").unwrap_or_else(|_| label.clone());
227    Ok(Box::new(FilmStock {
228        id: leak_str(string(table, "id")?),
229        short: leak_str(short),
230        process,
231        extra_cyan: None,
232        cl_layer,
233        label: leak_str(label),
234        note: leak_str(string(table, "provenance.note")?),
235        channels: [channel("red")?, channel("green")?, channel("blue")?],
236        base_density: numbers(table, "character.base_density")?
237            .try_into()
238            .map_err(|_| FilmFileError::Header("`base_density` needs three numbers".to_owned()))?,
239        grains_per_um2: numbers(table, "character.grains_per_um2")?
240            .try_into()
241            .map_err(|_| {
242                FilmFileError::Header("`grains_per_um2` needs three numbers".to_owned())
243            })?,
244        layers: layers.ok_or_else(|| FilmFileError::Header("no `layers` table".to_owned()))?,
245        // A v1 file must carry the dyes: a renderer cannot print a film whose
246        // primaries are zeros, and borrowing another film's would be a fact
247        // the file did not state.
248        dyes: dyes.ok_or_else(|| FilmFileError::Header("no `dyes` table".to_owned()))?,
249        // A reversal has no orange mask, and a missing table is how the format
250        // says so.
251        mask: mask.unwrap_or_else(|| Box::leak(Box::new([0.0; GRID_LEN]))),
252        sigma_um: sigma
253            .iter()
254            .any(|value| *value > 0.0)
255            .then(|| sigma.try_into().ok())
256            .flatten(),
257        reference_time_min: number(table, "development.reference_time_min").unwrap_or(6.5),
258        reference_temp_c: number(table, "development.reference_temp_c").unwrap_or(20.0),
259        provenance: Provenance {
260            publication: field("publication"),
261            page: field("page"),
262            retrieved: field("retrieved"),
263            checksum: field("checksum"),
264        },
265        // The file's own order, which is alphabetical: the compiled table has a
266        // reading order the model chose, and a file read here shows what it
267        // says rather than the order the binary would have put it in.
268        measured: measured(table),
269    }))
270}
271
272/// The `[measured]` section, one `(field, sentence)` per line, in file order.
273fn measured(
274    table: &std::collections::BTreeMap<String, Value>,
275) -> &'static [(&'static str, &'static str)] {
276    let entries: Vec<(&'static str, &'static str)> = table
277        .iter()
278        .filter_map(|(key, value)| {
279            let field = key.strip_prefix("measured.")?;
280            let Value::String(text) = value else {
281                return None;
282            };
283            Some((leak_str(field.to_owned()), leak_str(text.clone())))
284        })
285        .collect();
286    Box::leak(entries.into_boxed_slice())
287}
288
289fn strings(
290    table: &std::collections::BTreeMap<String, Value>,
291    key: &str,
292) -> Result<Vec<String>, FilmFileError> {
293    match table.get(key) {
294        Some(Value::List(items)) => items
295            .iter()
296            .map(|item| match item {
297                Value::String(value) => Ok(value.clone()),
298                _ => Err(FilmFileError::Header(format!("`{key}` is not all strings"))),
299            })
300            .collect(),
301        _ => Err(FilmFileError::Header(format!("`{key}` is missing"))),
302    }
303}
304
305fn read_table(
306    tables: &[u8],
307    at: &mut usize,
308    rows: usize,
309) -> Result<Vec<[f64; GRID_LEN]>, FilmFileError> {
310    let bytes = rows * GRID_LEN * 8;
311    let slice = tables.get(*at..*at + bytes).ok_or_else(|| {
312        FilmFileError::Format(format!("a table of {rows} rows runs past the file"))
313    })?;
314    *at += bytes;
315    slice
316        .chunks_exact(GRID_LEN * 8)
317        .map(|row| {
318            let values: Vec<f64> = row
319                .chunks_exact(8)
320                .map(|chunk| f64::from_le_bytes(chunk.try_into().expect("eight bytes")))
321                .collect();
322            grid(&values)
323        })
324        .collect()
325}
326
327fn leak_three(values: Vec<[f64; GRID_LEN]>) -> &'static [[f64; GRID_LEN]; 3] {
328    Box::leak(Box::new(
329        values.try_into().expect("three rows were requested"),
330    ))
331}
332
333fn leak_one(values: &[f64; GRID_LEN]) -> &'static [f64; GRID_LEN] {
334    Box::leak(Box::new(*values))
335}
336
337fn leak_str(value: String) -> &'static str {
338    Box::leak(value.into_boxed_str())
339}
340
341// ---------------------------------------------------------------- SHA-256
342
343const K: [u32; 64] = [
344    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
345    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
346    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
347    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
348    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
349    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
350    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
351    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
352];
353
354/// SHA-256 of a byte slice, as thirty-two bytes.
355#[must_use]
356pub fn sha256(data: &[u8]) -> [u8; 32] {
357    let mut state: [u32; 8] = [
358        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
359        0x5be0cd19,
360    ];
361    let mut message = data.to_vec();
362    let bits = (data.len() as u64) * 8;
363    message.push(0x80);
364    while message.len() % 64 != 56 {
365        message.push(0);
366    }
367    message.extend_from_slice(&bits.to_be_bytes());
368
369    for block in message.chunks_exact(64) {
370        let mut schedule = [0u32; 64];
371        for (index, word) in block.chunks_exact(4).enumerate() {
372            schedule[index] = u32::from_be_bytes(word.try_into().expect("four bytes"));
373        }
374        for index in 16..64 {
375            let s0 = schedule[index - 15].rotate_right(7)
376                ^ schedule[index - 15].rotate_right(18)
377                ^ (schedule[index - 15] >> 3);
378            let s1 = schedule[index - 2].rotate_right(17)
379                ^ schedule[index - 2].rotate_right(19)
380                ^ (schedule[index - 2] >> 10);
381            schedule[index] = schedule[index - 16]
382                .wrapping_add(s0)
383                .wrapping_add(schedule[index - 7])
384                .wrapping_add(s1);
385        }
386        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = state;
387        for index in 0..64 {
388            let choose = (e & f) ^ ((!e) & g);
389            let majority = (a & b) ^ (a & c) ^ (b & c);
390            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
391            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
392            let temp1 = h
393                .wrapping_add(choose)
394                .wrapping_add(s1)
395                .wrapping_add(K[index])
396                .wrapping_add(schedule[index]);
397            let temp2 = s0.wrapping_add(majority);
398            h = g;
399            g = f;
400            f = e;
401            e = d.wrapping_add(temp1);
402            d = c;
403            c = b;
404            b = a;
405            a = temp1.wrapping_add(temp2);
406        }
407        for (slot, value) in state.iter_mut().zip([a, b, c, d, e, f, g, h]) {
408            *slot = slot.wrapping_add(value);
409        }
410    }
411
412    let mut digest = [0u8; 32];
413    for (index, word) in state.iter().enumerate() {
414        digest[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes());
415    }
416    digest
417}
418
419fn hex(bytes: &[u8]) -> String {
420    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    /// One of the shipped `.kalofilm` files, by stock directory name.
428    fn film(name: &str) -> &'static [u8] {
429        kallo_data::FILMS
430            .iter()
431            .find(|source| source.name == name)
432            .unwrap_or_else(|| panic!("kallo-data carries no {name}"))
433            .bytes
434    }
435
436    /// The same bytes, in a file the reader can open.
437    fn written(prefix: &str, name: &str, bytes: &[u8]) -> std::path::PathBuf {
438        let path = std::env::temp_dir().join(format!("kallo-{prefix}-{name}.kalofilm"));
439        std::fs::write(&path, bytes).expect("write the data file");
440        path
441    }
442
443    /// The whole point of the format: the file and the compiled stock describe
444    /// the same film. If they ever disagree, the file is wrong or the compiler
445    /// is, and either way a render done from one is not the render done from
446    /// the other.
447    #[test]
448    fn the_files_match_the_compiled_stocks() {
449        for (id, directory) in [
450            ("fuji_pro400h", "fuji-pro400h"),
451            ("provia_100f", "provia-100f"),
452            ("ektachrome_e100", "ektachrome-e100"),
453            ("velvia_50", "velvia-50"),
454            ("kodak_portra_400", "portra-400"),
455            ("kodak_portra_800", "portra-800"),
456            ("kodak_trix400", "trix-400"),
457            ("ilford_hp5plus", "hp5-plus"),
458            ("kodak_ektar_100", "ektar-100"),
459            ("kodak_gold200", "gold-200"),
460            ("kodak_portra_160", "portra-160"),
461            ("kodak_tmax400", "tmax-400"),
462            ("ilford_delta400", "delta-400"),
463            ("vision3_50d", "vision3-50d"),
464            ("vision3_200t", "vision3-200t"),
465            ("vision3_250d", "vision3-250d"),
466            ("vision3_500t", "vision3-500t"),
467            ("fuji_nph400", "fuji-nph400"),
468            ("fuji_c200", "fuji-c200"),
469            ("fuji200", "fuji-200"),
470            ("fuji400", "fuji-400"),
471        ] {
472            let path = written("match", directory, film(directory));
473            let loaded = read(&path).unwrap_or_else(|error| panic!("{id}: {error}"));
474            let _ = std::fs::remove_file(&path);
475            let compiled = kallo_core::emulsion::film_stock(id).expect("a compiled stock");
476            assert_eq!(loaded.id, compiled.id);
477            assert_eq!(loaded.process, compiled.process);
478            assert_eq!(loaded.channels, compiled.channels);
479            assert_eq!(loaded.base_density, compiled.base_density);
480            assert_eq!(loaded.grains_per_um2, compiled.grains_per_um2);
481            assert_eq!(loaded.layers, compiled.layers);
482            assert_eq!(loaded.dyes, compiled.dyes);
483            assert_eq!(loaded.mask, compiled.mask);
484            assert_eq!(loaded.sigma_um, compiled.sigma_um);
485            assert_eq!(loaded.cl_layer.is_some(), compiled.cl_layer.is_some());
486        }
487    }
488
489    /// A byte of the tables changed is a film that is not this film, and the
490    /// digest is what says so.
491    #[test]
492    fn an_altered_file_is_refused() {
493        let mut bytes = film("fuji-pro400h").to_vec();
494        let middle = bytes.len() / 2;
495        bytes[middle] ^= 0xff;
496        let scratch = written("altered", "fuji-pro400h", &bytes);
497        match read(&scratch) {
498            Err(FilmFileError::Digest { .. }) => {}
499            other => panic!("expected a digest failure, got {other:?}"),
500        }
501        let _ = std::fs::remove_file(&scratch);
502    }
503
504    /// FIPS 180-2's published vectors: an empty message and the two standard
505    /// short abcs. A hash implementation without them is a guess.
506    #[test]
507    fn the_digest_matches_the_published_vectors() {
508        assert_eq!(
509            hex(&sha256(b"")),
510            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
511        );
512        assert_eq!(
513            hex(&sha256(b"abc")),
514            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
515        );
516        assert_eq!(
517            hex(&sha256(
518                b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"
519            )),
520            "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
521        );
522    }
523}