Color: convert input-only embedded profiles to the working space on open (#3876) (#3900)

An iPhone photo embeds Apple's Wide Color Sharing Profile, an input-class
profile with only AToB tables. Preserve kept it as the document's space, so
every composite into the document failed with "RGB profile without
matrix/TRC or BToA tags". Such a profile can't be a destination, so open
converts from it to the working space whatever the policy (Off still
discards it) and the report says inputOnly.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
HEOJUNFO
2026-10-12 09:57:11 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a58b6f3d9e
commit bb17dd555c
2 changed files with 66 additions and 3 deletions
+7
View File
@@ -296,6 +296,13 @@ impl Profile {
self.a2b.iter().all(Option::is_none) && (self.matrix.is_some() && self.trc.is_some() || self.gray_trc.is_some())
}
/// Whether colours can be converted into this profile for every intent. Input profiles with
/// only AToB tables (Apple's Wide Color Sharing Profile on iPhone photos) can't: they
/// describe a source but have no PCS → device path.
pub fn can_be_destination(&self) -> bool {
[Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation].into_iter().all(|i| self.pcs_to_device(i).is_ok())
}
fn lut_for(luts: &[Option<Lut>; 3], intent: Intent) -> Option<&Lut> {
let i = match intent {
Intent::Perceptual => 0,
+59 -3
View File
@@ -276,18 +276,22 @@ impl ColorState {
if !mismatch || linear_hdr {
return merge(base, json!({"action": "kept"}));
}
// An input-only profile can't be the document's space: nothing could be
// composited into it (#3876). Preserve converts it to the working space instead.
let input_only = !emb.can_be_destination();
let action = match policy {
Policy::Preserve => "kept",
Policy::Preserve if !input_only => "kept",
Policy::Off => {
doc.icc_profile = None;
"discarded"
}
Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
Policy::Preserve | Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
Ok(()) => "converted",
Err(_) => "kept",
},
};
merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}))
let report = merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}));
if input_only { merge(report, json!({"inputOnly": true})) } else { report }
}
None => {
// Untagged files are assumed to be in the working space; tag them when it is not
@@ -1397,6 +1401,58 @@ mod settings_tests {
assert_eq!(desc(&s.active().unwrap().doc).as_deref(), Some("Display P3"));
}
/// Display P3 as an input profile with only an AToB table, like Apple's Wide Color Sharing
/// Profile embedded in iPhone photos.
fn input_only_p3() -> Profile {
let p3 = Builtin::DisplayP3.profile();
let t = Transform::new(p3, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap();
let clut = photocraft_cms::Clut::sample(vec![9; 3], 3, |inp, out| t.eval(inp, out));
let ident = || photocraft_cms::pipeline::Stage::Curves(vec![photocraft_cms::Curve::Identity; 3]);
let lut = photocraft_cms::profile::Lut {
kind: photocraft_cms::profile::LutKind::Ab,
inputs: 3,
outputs: 3,
stages: vec![ident(), photocraft_cms::pipeline::Stage::Clut(clut), ident()],
};
let mut p = p3.clone();
p.class = photocraft_cms::ProfileClass::Input;
p.pcs = photocraft_cms::Pcs::Lab;
p.description = "Input-only P3".into();
(p.matrix, p.trc) = (None, None);
p.a2b = [Some(lut), None, None];
p.b2a = [None, None, None];
p.with_encoded_bytes()
}
#[test]
fn input_only_profiles_are_converted_on_open() {
// #3876: an iPhone photo's input-only profile was kept as the document's space, so
// compositing anything into it failed with "RGB profile without matrix/TRC or BToA tags".
let input = input_only_p3();
assert!(Builtin::DisplayP3.profile().can_be_destination());
assert!(!input.can_be_destination());
let mut s = Session::new();
let mut d = tagged("srgb");
d.icc_profile = Some(input.to_bytes());
let (photo, r) = s.open_document(d, None);
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool(), r["ask"].as_bool()), (Some("converted"), Some(true), Some(true)));
let doc = &s.active().unwrap().doc;
assert!(desc(doc).unwrap().starts_with("sRGB"));
// Converted from P3, not reinterpreted as sRGB.
assert!((doc.layers[0].surface().unwrap().pixel(1, 1)[0] - 0.2).abs() > 0.01);
let (other, _) = s.open_document(tagged("srgb"), None);
s.execute("layer.copyToDocument", json!({"document": photo, "source": other})).unwrap();
// Off still discards the profile, and profiles that can be a destination are still kept.
s.execute("edit.colorSettings", json!({"policyRgb": "off"})).unwrap();
let mut d = tagged("srgb");
d.icc_profile = Some(input.to_bytes());
let (_, r) = s.open_document(d, None);
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool()), (Some("discarded"), Some(true)));
s.execute("edit.colorSettings", json!({"policyRgb": "preserve"})).unwrap();
let (_, r) = s.open_document(tagged("display-p3"), None);
assert_eq!((r["action"].as_str(), r.get("inputOnly")), (Some("kept"), None));
}
/// A little-endian EXIF block whose Exif IFD holds ColorSpace = `space`.
fn exif_color_space(space: u16) -> Vec<u8> {
let mut b = b"Exif\0\0II\x2a\0\x08\0\0\0".to_vec();