Color: honour the EXIF sRGB tag on open; wire Ignore EXIF Profile Tag (#204) (#3026)

A file with no embedded ICC profile whose EXIF ColorSpace tag says sRGB
(what cameras write) now opens as sRGB, as in Photoshop: it is tagged
sRGB before the Color Settings policy runs, so it is kept, reported as a
mismatch, or converted like any embedded profile. Before, it was read as
untagged and took the working space, so with a wide working space such
as Display P3 its colours were reinterpreted.

Preferences › File Handling › Ignore EXIF Profile Tag (off by default)
restores the untagged reading, and is now shown in the dialog.

photocraft_codecs::exif_color_space reads the tag from the Exif sub-IFD,
bounds-checked; anything malformed reads as no tag. Settings that do
nothing: 43 → 42 of 150.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 16:07:03 -04:00
1 parent 56b192453e
commit 5f55a29d2b
5 files changed
+233 -6

No files matched your search

+138
View File
@@ -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<u16> {
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<usize> {
(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<u8> {
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);
}
}
+2
View File
@@ -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::{
+89 -2
View File
@@ -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<String>) -> (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<u8> {
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<u16>) -> 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();
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -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 |
|---|---|---|---|---|