From bb17dd555c161dbc6ca20c4cf856d1bf713456e5 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:57:11 +0900 Subject: [PATCH] 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) --- crates/cms/src/profile.rs | 7 ++++ crates/engine/src/color_cmds.rs | 62 +++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 6d646cc1e1..99f5710e5d 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -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; 3], intent: Intent) -> Option<&Lut> { let i = match intent { Intent::Perceptual => 0, diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index f6d4b1b18c..a68188bd3f 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -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 { let mut b = b"Exif\0\0II\x2a\0\x08\0\0\0".to_vec();