mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
co-authored by
Claude Opus 5.5
parent
a58b6f3d9e
commit
bb17dd555c
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user