Skip to content

Commit 3a7899e

Browse files
marceldenzerclaude
andcommitted
fix: preserve EXIF timezone offset in JPEG export
- combine_datetime_with_offset: write offset inline into DateTimeOriginal and CreateDate (e.g. 2026:06:05 11:33:26+02:00) per EXIF 2.31 spec - parse_offset_secs: strip surrounding quotes from offset string returned by kamadak-exif to prevent format errors - get_creation_date_from_path: correctly convert local datetime to UTC before setting file mtime, fixing +2h offset on exported JPEGs - Rawler fallback: read offset directly from RAW file when sidecar does not contain OffsetTime (backwards compatibility for old sidecars) Fixes: File Modification Date/Time showing +2h on export Fixes: DateTimeOriginal written without inline timezone offset Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 66c8dcd commit 3a7899e

1 file changed

Lines changed: 145 additions & 14 deletions

File tree

src-tauri/src/exif_processing.rs

Lines changed: 145 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
55

66
use crate::formats::is_raw_file;
77
use crate::image_processing::ImageMetadata;
8-
use chrono::{DateTime, NaiveDateTime, Utc};
8+
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
99
use exif::{Exif, In, Value};
1010
use little_exif::exif_tag::ExifTag;
1111
use little_exif::filetype::FileExtension;
@@ -87,6 +87,48 @@ fn clean_creation_datetime_str(s: &str) -> &str {
8787
s.trim().trim_matches('"').trim_matches('\'').trim()
8888
}
8989

90+
// Parse "+HH:MM" / "-HH:MM" offset into total seconds east of UTC.
91+
fn parse_offset_secs(s: &str) -> Option<i64> {
92+
let s = s.trim().trim_matches('"');
93+
if s.len() < 6 {
94+
return None;
95+
}
96+
let sign: i64 = if s.starts_with('-') { -1 } else { 1 };
97+
let rest = if s.starts_with('+') || s.starts_with('-') {
98+
&s[1..]
99+
} else {
100+
s
101+
};
102+
let mut parts = rest.splitn(2, ':');
103+
let h = parts.next()?.parse::<i64>().ok()?;
104+
let m = parts.next()?.parse::<i64>().ok()?;
105+
Some(sign * (h * 3600 + m * 60))
106+
}
107+
108+
// Combine a datetime string with an optional offset into EXIF inline format.
109+
// Input dt may use dashes or colons as date separator; output always uses colons.
110+
// Result: "YYYY:MM:DD HH:MM:SS+HH:MM" or "YYYY:MM:DD HH:MM:SS" if no offset.
111+
fn combine_datetime_with_offset(dt: &str, offset: Option<&str>) -> String {
112+
let dt = dt.trim_matches('"').trim();
113+
let normalized = if dt.len() >= 10 {
114+
format!("{}{}", dt[..10].replace('-', ":"), &dt[10..])
115+
} else {
116+
dt.to_string()
117+
};
118+
let bare = if normalized.len() >= 19 {
119+
&normalized[..19]
120+
} else {
121+
normalized.as_str()
122+
};
123+
match offset
124+
.map(|s| s.trim().trim_matches('"'))
125+
.filter(|s| !s.is_empty())
126+
{
127+
Some(off) => format!("{}{}", bare, off),
128+
None => bare.to_string(),
129+
}
130+
}
131+
90132
fn fmt_date_str(s: String) -> String {
91133
if let Some(dt) = parse_creation_datetime(&s) {
92134
return dt.format("%Y-%m-%d %H:%M:%S").to_string();
@@ -600,7 +642,29 @@ pub fn get_creation_date_from_path(path: &Path) -> DateTime<Utc> {
600642
&& let Some(dt_str) = map.get("DateTimeOriginal").or(map.get("CreateDate"))
601643
&& let Some(dt) = parse_creation_datetime(dt_str)
602644
{
603-
return DateTime::from_naive_utc_and_offset(dt, Utc);
645+
let offset_secs = map
646+
.get("OffsetTimeOriginal")
647+
.or_else(|| map.get("OffsetTime"))
648+
.and_then(|s| parse_offset_secs(s))
649+
.or_else(|| {
650+
// Sidecar may be stale (created before OffsetTime support); read from file.
651+
if !is_raw_file(path.to_string_lossy().as_ref()) {
652+
return None;
653+
}
654+
let loader = rawler::RawLoader::new();
655+
let raw_source = rawler::rawsource::RawSource::new(path).ok()?;
656+
let decoder = loader.get_decoder(&raw_source).ok()?;
657+
let meta = decoder
658+
.raw_metadata(&raw_source, &Default::default())
659+
.ok()?;
660+
meta.exif
661+
.offset_time_original
662+
.as_deref()
663+
.or(meta.exif.offset_time.as_deref())
664+
.and_then(parse_offset_secs)
665+
})
666+
.unwrap_or(0);
667+
return DateTime::from_naive_utc_and_offset(dt - Duration::seconds(offset_secs), Utc);
604668
}
605669

606670
if let Ok(file) = std::fs::File::open(path) {
@@ -624,11 +688,28 @@ pub fn get_creation_date_from_path(path: &Path) -> DateTime<Utc> {
624688
&& let Ok(decoder) = loader.get_decoder(&raw_source)
625689
&& let Ok(metadata) = decoder.raw_metadata(&raw_source, &Default::default())
626690
{
627-
if let Some(dt) = parse_raw_creation_date(metadata.exif.date_time_original.as_deref()) {
628-
return dt;
691+
let offset_secs = metadata
692+
.exif
693+
.offset_time_original
694+
.as_deref()
695+
.or(metadata.exif.offset_time.as_deref())
696+
.and_then(parse_offset_secs)
697+
.unwrap_or(0);
698+
if let Some(dt) =
699+
parse_creation_datetime(metadata.exif.date_time_original.as_deref().unwrap_or(""))
700+
{
701+
return DateTime::from_naive_utc_and_offset(
702+
dt - Duration::seconds(offset_secs),
703+
Utc,
704+
);
629705
}
630-
if let Some(dt) = parse_raw_creation_date(metadata.exif.create_date.as_deref()) {
631-
return dt;
706+
if let Some(dt) =
707+
parse_creation_datetime(metadata.exif.create_date.as_deref().unwrap_or(""))
708+
{
709+
return DateTime::from_naive_utc_and_offset(
710+
dt - Duration::seconds(offset_secs),
711+
Utc,
712+
);
632713
}
633714
}
634715
}
@@ -762,10 +843,27 @@ pub fn write_image_with_metadata(
762843
metadata.set_tag(ExifTag::ImageDescription(clean_s(val)));
763844
}
764845
if let Some(val) = map.get("DateTimeOriginal") {
765-
metadata.set_tag(ExifTag::DateTimeOriginal(clean_s(val)));
846+
let offset = map.get("OffsetTimeOriginal").map(String::as_str);
847+
metadata.set_tag(ExifTag::DateTimeOriginal(combine_datetime_with_offset(
848+
&clean_s(val),
849+
offset,
850+
)));
766851
}
767852
if let Some(val) = map.get("CreateDate") {
768-
metadata.set_tag(ExifTag::CreateDate(clean_s(val)));
853+
let offset = map.get("OffsetTime").map(String::as_str);
854+
metadata.set_tag(ExifTag::CreateDate(combine_datetime_with_offset(
855+
&clean_s(val),
856+
offset,
857+
)));
858+
}
859+
if let Some(val) = map.get("OffsetTime") {
860+
metadata.set_tag(ExifTag::OffsetTime(clean_s(val)));
861+
}
862+
if let Some(val) = map.get("OffsetTimeOriginal") {
863+
metadata.set_tag(ExifTag::OffsetTimeOriginal(clean_s(val)));
864+
}
865+
if let Some(val) = map.get("OffsetTimeDigitized") {
866+
metadata.set_tag(ExifTag::OffsetTimeDigitized(clean_s(val)));
769867
}
770868
if let Some(val) = map.get("FNumber")
771869
&& let Some(ur) = parse_ur64(val)
@@ -842,10 +940,31 @@ pub fn write_image_with_metadata(
842940
metadata.set_tag(ExifTag::Copyright(get_string_val(f)));
843941
}
844942
if let Some(f) = exif_obj.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) {
845-
metadata.set_tag(ExifTag::DateTimeOriginal(get_string_val(f)));
943+
let offset = exif_obj
944+
.get_field(exif::Tag::OffsetTimeOriginal, exif::In::PRIMARY)
945+
.map(|of| get_string_val(of));
946+
metadata.set_tag(ExifTag::DateTimeOriginal(combine_datetime_with_offset(
947+
&get_string_val(f),
948+
offset.as_deref(),
949+
)));
846950
}
847951
if let Some(f) = exif_obj.get_field(exif::Tag::DateTime, exif::In::PRIMARY) {
848-
metadata.set_tag(ExifTag::CreateDate(get_string_val(f)));
952+
let offset = exif_obj
953+
.get_field(exif::Tag::OffsetTime, exif::In::PRIMARY)
954+
.map(|of| get_string_val(of));
955+
metadata.set_tag(ExifTag::CreateDate(combine_datetime_with_offset(
956+
&get_string_val(f),
957+
offset.as_deref(),
958+
)));
959+
}
960+
if let Some(f) = exif_obj.get_field(exif::Tag::OffsetTime, exif::In::PRIMARY) {
961+
metadata.set_tag(ExifTag::OffsetTime(get_string_val(f)));
962+
}
963+
if let Some(f) = exif_obj.get_field(exif::Tag::OffsetTimeOriginal, exif::In::PRIMARY) {
964+
metadata.set_tag(ExifTag::OffsetTimeOriginal(get_string_val(f)));
965+
}
966+
if let Some(f) = exif_obj.get_field(exif::Tag::OffsetTimeDigitized, exif::In::PRIMARY) {
967+
metadata.set_tag(ExifTag::OffsetTimeDigitized(get_string_val(f)));
849968
}
850969
if let Some(f) = exif_obj.get_field(exif::Tag::FNumber, exif::In::PRIMARY)
851970
&& let exif::Value::Rational(v) = &f.value
@@ -955,11 +1074,23 @@ pub fn write_image_with_metadata(
9551074
if let Some(copyright) = exif.copyright {
9561075
metadata.set_tag(ExifTag::Copyright(copyright));
9571076
}
958-
if let Some(dt) = exif.date_time_original {
959-
metadata.set_tag(ExifTag::DateTimeOriginal(dt));
1077+
if let Some(ref dt) = exif.date_time_original {
1078+
let combined =
1079+
combine_datetime_with_offset(dt, exif.offset_time_original.as_deref());
1080+
metadata.set_tag(ExifTag::DateTimeOriginal(combined));
1081+
}
1082+
if let Some(ref dt) = exif.create_date {
1083+
let combined = combine_datetime_with_offset(dt, exif.offset_time.as_deref());
1084+
metadata.set_tag(ExifTag::CreateDate(combined));
1085+
}
1086+
if let Some(ot) = exif.offset_time {
1087+
metadata.set_tag(ExifTag::OffsetTime(ot));
1088+
}
1089+
if let Some(oto) = exif.offset_time_original {
1090+
metadata.set_tag(ExifTag::OffsetTimeOriginal(oto));
9601091
}
961-
if let Some(dt) = exif.create_date {
962-
metadata.set_tag(ExifTag::CreateDate(dt));
1092+
if let Some(otd) = exif.offset_time_digitized {
1093+
metadata.set_tag(ExifTag::OffsetTimeDigitized(otd));
9631094
}
9641095
if let Some(lens_make) = exif.lens_make {
9651096
metadata.set_tag(ExifTag::LensMake(lens_make));

0 commit comments

Comments
 (0)