diff --git a/crates/codecs/src/exif_color.rs b/crates/codecs/src/exif_color.rs new file mode 100644 index 000000000..4259bb15c --- /dev/null +++ b/crates/codecs/src/exif_color.rs @@ -0,0 +1,138 @@ +//! The EXIF ColorSpace tag (0xA001, in the Exif sub-IFD that IFD0's tag 0x8769 points to): +//! 1 = sRGB, 0xFFFF = uncalibrated (EXIF 2.3, §4.6.5). +//! +//! Cameras write it instead of an ICC profile. Photoshop opens a file with no embedded profile but +//! ColorSpace = sRGB as sRGB, unless Preferences › File Handling › Ignore EXIF Profile Tag is on. +//! Anything malformed, truncated or missing reads as no tag. + +use crate::orientation::{Order, ifd0_entries, tiff_body}; + +/// IFD0's pointer to the Exif sub-IFD. +const TAG_EXIF_IFD: u16 = 0x8769; +/// The ColorSpace tag in the Exif sub-IFD. +const TAG_COLOR_SPACE: u16 = 0xA001; +const TYPE_SHORT: u16 = 3; +const TYPE_LONG: u16 = 4; +/// TIFF type 13: an IFD offset (some writers use it for sub-IFD pointers). +const TYPE_IFD: u16 = 13; +/// Bytes per classic TIFF IFD entry; EXIF is never BigTIFF. +const ENTRY: usize = 12; +/// No IFD can start inside the 8-byte TIFF header. +const HEADER: usize = 8; + +/// The ColorSpace value meaning sRGB. +pub const EXIF_COLOR_SPACE_SRGB: u16 = 1; + +/// The EXIF ColorSpace value of a TIFF-structured block (an EXIF payload with or without the +/// `Exif\0\0` prefix), or `None` when it has none or it is malformed. +pub fn exif_color_space(exif: &[u8]) -> Option { + let b = tiff_body(exif); + let (order, first, count, entry_bytes, big_tiff) = ifd0_entries(b)?; + if big_tiff { + return None; + } + let ptr = find(order, b, first, count, entry_bytes, TAG_EXIF_IFD)?; + if !matches!(order.u16(b, ptr.checked_add(2)?)?, TYPE_LONG | TYPE_IFD) || order.u32(b, ptr.checked_add(4)?)? != 1 { + return None; + } + let ifd = usize::try_from(order.u32(b, ptr.checked_add(8)?)?).ok()?; + if ifd < HEADER { + return None; + } + let n = usize::from(order.u16(b, ifd)?); + let first = ifd.checked_add(2)?; + // The whole table must be present. + if first.checked_add(n.checked_mul(ENTRY)?)? > b.len() { + return None; + } + let e = find(order, b, first, n, ENTRY, TAG_COLOR_SPACE)?; + if order.u32(b, e.checked_add(4)?)? != 1 { + return None; + } + match order.u16(b, e.checked_add(2)?)? { + TYPE_SHORT => order.u16(b, e.checked_add(8)?), + TYPE_LONG => u16::try_from(order.u32(b, e.checked_add(8)?)?).ok(), + _ => None, + } +} + +/// Offset of the first entry tagged `tag` in the IFD whose `count` entries start at `first`. +fn find(order: Order, b: &[u8], first: usize, count: usize, entry_bytes: usize, tag: u16) -> Option { + (0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).find(|&e| order.u16(b, e) == Some(tag)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A little- or big-endian EXIF block: IFD0 holding only the Exif pointer, then an Exif IFD + /// holding `entries` (tag, type, count, value). + fn exif(big_endian: bool, entries: &[(u16, u16, u32, u32)]) -> Vec { + let u16b = |v: u16| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() }; + let u32b = |v: u32| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() }; + let mut b = if big_endian { b"MM\0\x2a".to_vec() } else { b"II\x2a\0".to_vec() }; + b.extend(u32b(8)); + // IFD0: one entry, then the next-IFD pointer. The Exif IFD starts right after (8 + 2 + 12 + 4). + b.extend(u16b(1)); + b.extend(u16b(TAG_EXIF_IFD)); + b.extend(u16b(TYPE_LONG)); + b.extend(u32b(1)); + b.extend(u32b(26)); + b.extend(u32b(0)); + b.extend(u16b(entries.len() as u16)); + for &(tag, ty, count, value) in entries { + b.extend(u16b(tag)); + b.extend(u16b(ty)); + b.extend(u32b(count)); + // SHORT values are left-justified in the 4-byte field. + if ty == TYPE_SHORT { + b.extend(u16b(value as u16)); + b.extend([0, 0]); + } else { + b.extend(u32b(value)); + } + } + b.extend(u32b(0)); + b + } + + #[test] + fn reads_the_color_space_in_either_byte_order() { + for big in [false, true] { + let srgb = exif(big, &[(0x9000, 7, 4, 0x3032_3330), (TAG_COLOR_SPACE, TYPE_SHORT, 1, 1)]); + assert_eq!(exif_color_space(&srgb), Some(EXIF_COLOR_SPACE_SRGB), "big endian: {big}"); + let mut prefixed = b"Exif\0\0".to_vec(); + prefixed.extend(&srgb); + assert_eq!(exif_color_space(&prefixed), Some(1)); + assert_eq!(exif_color_space(&exif(big, &[(TAG_COLOR_SPACE, TYPE_SHORT, 1, 0xFFFF)])), Some(0xFFFF)); + assert_eq!(exif_color_space(&exif(big, &[(TAG_COLOR_SPACE, TYPE_LONG, 1, 1)])), Some(1), "LONG is accepted"); + } + } + + #[test] + fn missing_or_malformed_reads_as_no_tag() { + assert_eq!(exif_color_space(&exif(false, &[(0x9000, 7, 4, 0)])), None, "no ColorSpace entry"); + assert_eq!(exif_color_space(&exif(false, &[(TAG_COLOR_SPACE, TYPE_SHORT, 2, 1)])), None, "count ≠ 1"); + assert_eq!(exif_color_space(&exif(false, &[(TAG_COLOR_SPACE, 2, 1, 1)])), None, "ASCII type"); + assert_eq!(exif_color_space(&exif(false, &[(TAG_COLOR_SPACE, TYPE_LONG, 1, 0x1_0000)])), None, "LONG over u16"); + let good = exif(false, &[(TAG_COLOR_SPACE, TYPE_SHORT, 1, 1)]); + // Every truncation is rejected without panicking. + for len in 0..good.len() - 4 { + assert_eq!(exif_color_space(&good[..len]), None, "truncated to {len}"); + } + // An Exif pointer past the end, into the header, or of the wrong type. + for (at, value) in [(18usize, 0xFFFF_FFF0u32), (18, 4), (18, u32::MAX)] { + let mut b = good.clone(); + b[at..at + 4].copy_from_slice(&value.to_le_bytes()); + assert_eq!(exif_color_space(&b), None, "pointer {value:#x}"); + } + let mut b = good.clone(); + b[12..14].copy_from_slice(&TYPE_SHORT.to_le_bytes()); + assert_eq!(exif_color_space(&b), None, "pointer type SHORT"); + // An Exif IFD claiming more entries than the block holds. + let mut b = good; + b[26..28].copy_from_slice(&u16::MAX.to_le_bytes()); + assert_eq!(exif_color_space(&b), None); + assert_eq!(exif_color_space(b"not exif"), None); + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index dd04cc3f5..b7722e9cf 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -15,6 +15,7 @@ mod codecs; mod error; +pub mod exif_color; mod fidelity; mod format; mod image; @@ -28,6 +29,7 @@ pub use crate::codecs::png::encode_indexed as encode_png_indexed; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; +pub use crate::exif_color::{EXIF_COLOR_SPACE_SRGB, exif_color_space}; pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with}; pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension}; pub use crate::image::{ diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index d3ccf9e83..f6d4b1b18 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -238,11 +238,28 @@ impl ColorState { /// Apply the colour management policy to a document being opened. Returns what happened /// (`action`: kept|converted|discarded|assigned|untagged) and whether to ask the user. - pub fn open_policy(&self, doc: &mut Document) -> Value { + /// + /// An RGB file with no embedded profile whose EXIF ColorSpace tag says sRGB (as cameras write) + /// is opened as sRGB, as in Photoshop; `ignore_exif` (Preferences › File Handling › Ignore + /// EXIF Profile Tag) treats it as untagged instead. The report then has `exif: true`. + pub fn open_policy(&self, doc: &mut Document, ignore_exif: bool) -> Value { let space = mode_space(doc.mode); if !matches!(space, ColorSpace::Rgb | ColorSpace::Cmyk | ColorSpace::Gray) { return json!({"action": "kept"}); } + let exif_srgb = space == ColorSpace::Rgb + && !ignore_exif + && doc.icc_profile.is_none() + && doc.metadata.exif.as_deref().and_then(|e| photocraft_codecs::exif_color_space(e)) == Some(photocraft_codecs::EXIF_COLOR_SPACE_SRGB); + if exif_srgb { + doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes()); + } + let report = self.apply_open_policy(doc, space); + if exif_srgb { merge(report, json!({"exif": true})) } else { report } + } + + /// [`ColorState::open_policy`] once the document's profile is known. + fn apply_open_policy(&self, doc: &mut Document, space: ColorSpace) -> Value { let working = self.working(doc.mode); let policy = self.settings.policy(doc.mode); let embedded = doc.icc_profile.as_ref().and_then(|b| profile_from_bytes(b).ok()).filter(|p| p.color_space == space); @@ -1012,7 +1029,7 @@ impl Session { /// convert to the working space, or discard the embedded profile). Returns the document /// index and a report (`action`, `mismatch`, `ask`) the UI can turn into a prompt. pub fn open_document(&mut self, mut doc: Document, path: Option) -> (usize, Value) { - let report = self.color.open_policy(&mut doc); + let report = self.color.open_policy(&mut doc, self.prefs().file_handling.ignore_exif_profile_tag); (self.add_document(doc, path), report) } } @@ -1380,6 +1397,76 @@ mod settings_tests { assert_eq!(desc(&s.active().unwrap().doc).as_deref(), Some("Display P3")); } + /// 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(); + // IFD0: the Exif IFD pointer (LONG) to offset 26, then no next IFD. + b.extend([1, 0, 0x69, 0x87, 4, 0, 1, 0, 0, 0, 26, 0, 0, 0, 0, 0, 0, 0]); + // Exif IFD: ColorSpace (SHORT). + b.extend([1, 0, 0x01, 0xA0, 3, 0, 1, 0, 0, 0]); + b.extend(space.to_le_bytes()); + b.extend([0, 0, 0, 0, 0, 0]); + b + } + + fn untagged_camera_file(space: Option) -> Document { + let mut d = tagged("srgb"); + d.icc_profile = None; + d.metadata.exif = space.map(|v| Arc::new(exif_color_space(v))); + d + } + + /// The tag survives a real JPEG: written without an ICC profile, read back, opened as sRGB. + #[test] + fn exif_srgb_jpeg_round_trip() { + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"workingRgb": "display-p3"})).unwrap(); + let r = photocraft_io::export(&untagged_camera_file(Some(1)), "x.jpg", &photocraft_io::ExportOptions::default()).unwrap(); + let back = photocraft_io::import("x.jpg", &r.bytes).unwrap().document; + assert!(back.icc_profile.is_none(), "no ICC profile in the file"); + let (_, r) = s.open_document(back, None); + assert_eq!((r["action"].as_str(), r["exif"].as_bool()), (Some("kept"), Some(true))); + assert!(desc(&s.active().unwrap().doc).unwrap().starts_with("sRGB")); + } + + /// #204: a camera file with no ICC profile but EXIF ColorSpace = sRGB opens as sRGB, as in + /// Photoshop; Ignore EXIF Profile Tag opens it untagged (the working space). + #[test] + fn exif_srgb_tag_unless_ignored() { + let mut s = Session::new(); + // Working sRGB: tagged sRGB, nothing to ask. + let (_, r) = s.open_document(untagged_camera_file(Some(1)), None); + assert_eq!((r["action"].as_str(), r["exif"].as_bool(), r.get("ask")), (Some("kept"), Some(true), None)); + assert!(desc(&s.active().unwrap().doc).unwrap().starts_with("sRGB")); + // Working Display P3, Preserve: it stays sRGB instead of being read as P3. + s.execute("edit.colorSettings", json!({"policyRgb": "preserve", "workingRgb": "display-p3"})).unwrap(); + let (_, r) = s.open_document(untagged_camera_file(Some(1)), None); + assert_eq!((r["action"].as_str(), r["mismatch"].as_bool(), r["exif"].as_bool()), (Some("kept"), Some(true), Some(true))); + assert!(desc(&s.active().unwrap().doc).unwrap().starts_with("sRGB")); + // Convert: converted from sRGB to P3, so the pixel values change. + s.execute("edit.colorSettings", json!({"policyRgb": "convert"})).unwrap(); + let (_, r) = s.open_document(untagged_camera_file(Some(1)), None); + assert_eq!(r["action"], "converted"); + let d = &s.active().unwrap().doc; + assert_eq!(desc(d).as_deref(), Some("Display P3")); + assert!((d.layers[0].surface().unwrap().pixel(1, 1)[0] - 0.2).abs() > 0.01); + // Uncalibrated, no EXIF, or Ignore EXIF Profile Tag on: untagged, so the working space. + s.execute("edit.colorSettings", json!({"policyRgb": "preserve"})).unwrap(); + for (space, ignore) in [(Some(0xFFFF), false), (None, false), (Some(1), true)] { + s.execute("prefs.set", json!({"path": "fileHandling.ignoreExifProfileTag", "value": ignore})).unwrap(); + let (_, r) = s.open_document(untagged_camera_file(space), None); + assert_eq!((r["action"].as_str(), r.get("exif")), (Some("assigned"), None), "{space:?}, ignore {ignore}"); + assert_eq!(desc(&s.active().unwrap().doc).as_deref(), Some("Display P3")); + } + // An embedded profile always wins over the EXIF tag. + s.execute("prefs.set", json!({"path": "fileHandling.ignoreExifProfileTag", "value": false})).unwrap(); + let mut d = tagged("adobe-rgb-compat"); + d.metadata.exif = Some(Arc::new(exif_color_space(1))); + let (_, r) = s.open_document(d, None); + assert_eq!((r["action"].as_str(), r.get("exif")), (Some("kept"), None)); + assert_eq!(desc(&s.active().unwrap().doc), Some(Builtin::from_id("adobe-rgb-compat").unwrap().profile().description.clone())); + } + #[test] fn mode_conversion_uses_working_spaces() { let mut s = Session::new(); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3cb420fcb..d30cbd0d7 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -476,6 +476,7 @@ pub struct FileHandling { pub autosave_minutes: u32, /// Offer to reopen recovered documents at launch. pub recover_on_launch: bool, + /// Open a file with no ICC profile as untagged even when its EXIF ColorSpace tag says sRGB. pub ignore_exif_profile_tag: bool, pub ask_before_saving_layered_tiff: bool, pub maximize_psd_compatibility: Ask, @@ -999,7 +1000,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "workspace.enableFloatingDocumentWindowDocking", "workspace.enableNarrowOptionsBar", "fileHandling.saveInBackground", - "fileHandling.ignoreExifProfileTag", "fileHandling.maximizePsdCompatibility", "performance.cacheLevels", "performance.effectCacheMb", diff --git a/docs/scorecard.md b/docs/scorecard.md index 81e93ea2b..fdba39bd4 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 12 | 9 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 25 | 16 | 2 | | -| [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 31 of 154 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 30 of 154 (target 0) | | [Type](#type) | 3 | 3 | 3 | | | [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 31 of 154** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 30 of 154** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---|