Files
lightcraft/crates/engine/src/proof.rs
T
Andrew FrahnandClaude Opus 5.5 bac0f75551 Soft proofing against printer ICC profiles: intent, Simulate Paper & Ink, device gamut warning
Soft Proofing's Profile menu takes a printer / paper ICC profile (Other…;
`view.softProof {profile, intent, paperInk}`), RGB or CMYK, matrix or LUT.
The CMS (moxcms) builds a 33^3 proof table over the working space: forward with
the chosen intent (Perceptual / Relative), back relative colorimetric; the
destination gamut warning comes from the colorimetric round trip. Simulate
Paper & Ink renders absolute colorimetric: the profile's media white replaces
screen white and the ink black is unscaled. The pipeline gets the table by id
(`Proof::profile`, `register_proof_profile`), so Proof stays Copy and render
keys include it. Proof copies are named after the profile.

Sources: ICC.1:2022 (rendering intents, media white point, absolute
colorimetric); own design for the proof table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:46 +11:00

63 lines
3.0 KiB
Rust

//! Soft proofing against an output-device ICC profile (a printer and paper): loads the profile,
//! builds its proof table through the CMS and registers it with the pipeline, which a
//! [`Proof`](lightcraft_pipeline::Proof) then names by id.
use lightcraft_codecs::display::MAX_PROFILE_BYTES;
pub use lightcraft_codecs::proof::ProofIntent;
/// A device profile ready to proof against.
#[derive(Clone, Debug, PartialEq)]
pub struct ProofProfile {
/// Pass as [`Proof::profile`](lightcraft_pipeline::Proof::profile).
pub id: u64,
/// The profile's description, else the file name.
pub name: String,
}
/// Load the profile at `path` for proofing with `intent`, simulating paper and ink or not.
pub fn load(path: &str, intent: ProofIntent, paper_ink: bool) -> Result<ProofProfile, String> {
let path = path.trim();
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if meta.len() > MAX_PROFILE_BYTES as u64 {
return Err(format!("{path}: too large for an ICC profile"));
}
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let fallback = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
from_bytes(&bytes, intent, paper_ink, &fallback).map_err(|e| format!("{path}: {e}"))
}
/// [`load`] from the profile's bytes; `fallback_name` names a profile without a description.
pub fn from_bytes(bytes: &[u8], intent: ProofIntent, paper_ink: bool, fallback_name: &str) -> Result<ProofProfile, String> {
let table = lightcraft_codecs::proof::proof_table(bytes, intent, paper_ink)?;
let lut = lightcraft_pipeline::ProofLut::new(table.n, table.data).ok_or("the profile's tables give no usable colours")?;
let id = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(bytes, intent, paper_ink).hash(&mut h);
h.finish()
};
let id = lightcraft_pipeline::register_proof_profile(id, lut);
Ok(ProofProfile { id, name: table.description.unwrap_or_else(|| fallback_name.to_string()) })
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_codecs::space::NamedSpace;
#[test]
fn profiles_register_by_content_and_options() {
let p3 = lightcraft_codecs::icc::write_named(NamedSpace::DisplayP3);
let a = from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap();
let b = from_bytes(&p3, ProofIntent::Perceptual, true, "x").unwrap();
assert_ne!(a.id, b.id);
assert_eq!(a, from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap());
assert!(lightcraft_pipeline::proof_profile(a.id).is_some());
assert!(!a.name.is_empty());
assert!(from_bytes(b"nope", ProofIntent::Relative, false, "x").is_err());
assert!(load("/nonexistent/printer.icc", ProofIntent::Relative, false).is_err());
assert_eq!(ProofIntent::parse("Relative"), Some(ProofIntent::Relative));
assert_eq!(ProofIntent::parse("saturation"), None);
}
}