1use 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
30const GRID_LEN: usize = 71;
32const MAGIC: &[u8; 8] = b"KALOFILM";
33
34#[derive(Debug)]
36pub enum FilmFileError {
37 Io(std::io::Error),
39 Format(String),
41 Digest {
43 expected: String,
45 found: String,
47 },
48 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
74pub fn read(path: impl AsRef<Path>) -> Result<&'static FilmStock, FilmFileError> {
76 read_owned(path).map(|stock| &*Box::leak(stock))
77}
78
79pub 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 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 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 dyes: dyes.ok_or_else(|| FilmFileError::Header("no `dyes` table".to_owned()))?,
249 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 measured: measured(table),
269 }))
270}
271
272fn 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
341const 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#[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 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 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 #[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 #[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 #[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}