diff --git a/src/core/file_format.rs b/src/core/file_format.rs index 0d9b866fb..d35d5a26a 100644 --- a/src/core/file_format.rs +++ b/src/core/file_format.rs @@ -257,6 +257,9 @@ pub enum FileFormat { /// glTF 3D model format GLTF, + /// Garmin FIT activity file format + FIT, + /// FITS astronomy format FITS, @@ -331,6 +334,9 @@ pub enum FileFormat { /// FLIR Public image Format (.fpf) -- thermal camera raw image FPF, + /// DICOM medical image (.dcm) + DICOM, + /// Unknown or unsupported format Unknown, } @@ -417,6 +423,7 @@ impl FileFormat { FileFormat::STL => "STL", FileFormat::OBJ => "OBJ", FileFormat::GLTF => "glTF", + FileFormat::FIT => "FIT", FileFormat::FITS => "FITS", FileFormat::HDF5 => "HDF5", FileFormat::VCF => "vCard", @@ -441,6 +448,7 @@ impl FileFormat { FileFormat::VRD => "VRD", FileFormat::DR4 => "DR4", FileFormat::FPF => "FPF", + FileFormat::DICOM => "DICOM", FileFormat::Unknown => "Unknown", } } @@ -529,7 +537,8 @@ impl FileFormat { FileFormat::STL => &["stl"], FileFormat::OBJ => &["obj"], FileFormat::GLTF => &["gltf", "glb"], - FileFormat::FITS => &["fits", "fit"], + FileFormat::FIT => &["fit"], + FileFormat::FITS => &["fits"], FileFormat::HDF5 => &["h5", "hdf5"], FileFormat::VCF => &["vcf", "vcard"], FileFormat::ICS => &["ics", "ical"], @@ -553,6 +562,7 @@ impl FileFormat { FileFormat::VRD => &["vrd"], FileFormat::DR4 => &["dr4"], FileFormat::FPF => &["fpf"], + FileFormat::DICOM => &["dcm", "dicom"], FileFormat::Unknown => &[], } } diff --git a/src/core/format_dispatch.rs b/src/core/format_dispatch.rs index 9bf96cdeb..85af7e8fc 100644 --- a/src/core/format_dispatch.rs +++ b/src/core/format_dispatch.rs @@ -65,7 +65,8 @@ use crate::parsers::quicktime::parse_quicktime_metadata; use crate::parsers::specialized::dwg::parse_dwg_metadata; use crate::parsers::specialized::dxf::parse_dxf_metadata; use crate::parsers::specialized::evtx::parse_evtx_metadata; -use crate::parsers::specialized::fits::parse_fits_metadata; +use crate::parsers::specialized::fit::parse_fit_metadata; +use crate::parsers::specialized::fits::{parse_dicom_metadata, parse_fits_metadata}; use crate::parsers::specialized::gltf::parse_gltf_metadata; use crate::parsers::specialized::hdf5::parse_hdf5_metadata; use crate::parsers::specialized::lnk::parse_lnk_metadata; @@ -187,7 +188,9 @@ pub fn dispatch_format_parser(reader: &dyn FileReader, format: FileFormat) -> Re FileFormat::STL => convert_string_error(parse_stl_metadata(reader), "STL"), FileFormat::OBJ => convert_string_error(parse_obj_metadata(reader), "OBJ"), FileFormat::GLTF => convert_string_error(parse_gltf_metadata(reader), "glTF"), + FileFormat::FIT => convert_string_error(parse_fit_metadata(reader), "FIT"), FileFormat::FITS => convert_string_error(parse_fits_metadata(reader), "FITS"), + FileFormat::DICOM => parse_dicom_metadata(reader), FileFormat::HDF5 => convert_string_error(parse_hdf5_metadata(reader), "HDF5"), FileFormat::VCF => convert_string_error(parse_vcf_metadata(reader), "VCF"), FileFormat::TXT => convert_string_error(parse_txt_metadata(reader), "TXT"), diff --git a/src/core/tiff_helpers.rs b/src/core/tiff_helpers.rs index ce1573e81..828088fc8 100644 --- a/src/core/tiff_helpers.rs +++ b/src/core/tiff_helpers.rs @@ -51,6 +51,7 @@ const INTEROPERABILITY_IFD_POINTER: u16 = 0xA005; /// MakerNote (0x927C): the manufacturer's private block in the EXIF IFD. const MAKERNOTE: u16 = 0x927C; +const TAG_SUBFILE_TYPE: u16 = 0x00FE; // Image-carrying tags some cameras (Samsung SPH-A800/A940, Canon XL H1) write // into the Interoperability IFD alongside - or instead of - the DCF tags. @@ -821,6 +822,12 @@ pub fn parse_exif_subifd( let mut exif_makernote_data: Vec<&[u8]> = Vec::new(); let mut interop_ifd_offset: Option = None; + // ExifTool's MakerNote Condition list reads `$$self{Make}` and + // `$$self{Model}`, DataMembers set while walking IFD0 (before this + // sub-IFD) with trailing whitespace stripped (Exif.pm:585,595). + let make = trimmed_data_member(metadata, "IFD0:Make"); + let model = trimmed_data_member(metadata, "IFD0:Model"); + // First pass: convert tags and capture special pointers for (tag_id, field_type, value_count, raw_bytes) in &exif_tags { // Convert Cow<[u8]> to &[u8] for processing @@ -841,7 +848,13 @@ pub fn parse_exif_subifd( continue; } - let tag_name = lookup_tag_name(*tag_id, "ExifIFD"); + let resolved_name = lookup_tag_name(*tag_id, "ExifIFD"); + let (tag_name, special_value) = if *tag_id == MAKERNOTE { + special_makernote_value(&resolved_name, bytes, &make, &model) + .map_or((resolved_name, None), |(name, value)| (name, Some(value))) + } else { + (resolved_name, None) + }; let base_name = tag_name .split_once(':') .map_or(tag_name.as_str(), |(_, name)| name); @@ -867,7 +880,9 @@ pub fn parse_exif_subifd( // producing the final display string directly instead of leaving // an opaque `TagValue::Binary` for a later stage that cannot // decode it. - let tag_value = if base_name == "CompositeImageExposureTimes" { + let tag_value = if let Some(value) = special_value { + value + } else if base_name == "CompositeImageExposureTimes" { TagValue::String(format_composite_image_exposure_times(bytes, byte_order)) } else { raw_bytes_to_tag_value(bytes, *field_type, *value_count, *tag_id, byte_order) @@ -1105,6 +1120,801 @@ pub fn parse_gps_subifd( // IFD1 (Thumbnail IFD) // ============================================================================= +fn contextual_tag_name(resolved: &str, base_name: &str) -> String { + resolved.split_once(':').map_or_else( + || base_name.to_string(), + |(group, _)| format!("{group}:{base_name}"), + ) +} + +/// Reads a string tag the way ExifTool keeps its `Make`/`Model` DataMembers: +/// trailing whitespace stripped (`RawConv => '$val =~ s/\s+$//; ...'`, +/// Exif.pm:585,595). Trailing NULs are also dropped defensively; an absent +/// tag reads as the empty string, which fails every `eq`/prefix test below +/// exactly as Perl's `undef` fails them. +fn trimmed_data_member(metadata: &MetadataMap, key: &str) -> String { + metadata.get_string(key).map_or_else(String::new, |value| { + value + .trim_end_matches(['\0', ' ', '\t', '\n', '\r', '\x0b', '\x0c']) + .to_string() + }) +} + +/// Does `val` start with any of `prefixes`? +fn any_prefix(val: &[u8], prefixes: &[&[u8]]) -> bool { + prefixes.iter().any(|prefix| val.starts_with(prefix)) +} + +/// ASCII-case-insensitive `starts_with` over a string (Perl `=~ /^.../i`). +fn ci_starts_with(text: &str, prefix: &str) -> bool { + let text = text.as_bytes(); + let prefix = prefix.as_bytes(); + text.len() >= prefix.len() && text[..prefix.len()].eq_ignore_ascii_case(prefix) +} + +/// ASCII-case-insensitive `starts_with` over value bytes. +fn ci_val_prefix(val: &[u8], prefix: &[u8]) -> bool { + val.len() >= prefix.len() && val[..prefix.len()].eq_ignore_ascii_case(prefix) +} + +/// `MakerNoteKodak7`'s serial-number shape, +/// `/^[CK][A-Z\d]{3} ?[A-Z\d]{1,2}\d{2}[A-Z\d]\d{4}[ \0]/` (MakerNotes.pm). +/// The optional space and the one-or-two alphanumerics are tried in every +/// combination, as the regex engine's backtracking would. +fn kodak7_serial(val: &[u8]) -> bool { + fn alnum(byte: u8) -> bool { + byte.is_ascii_uppercase() || byte.is_ascii_digit() + } + if val.len() < 12 + || !(val[0] == b'C' || val[0] == b'K') + || !val[1..4].iter().copied().all(alnum) + { + return false; + } + for with_space in [true, false] { + let start = if with_space { + if val.get(4) != Some(&b' ') { + continue; + } + 5 + } else { + 4 + }; + for id_len in [2usize, 1] { + let Some(id) = val.get(start..start + id_len) else { + continue; + }; + if !id.iter().copied().all(alnum) { + continue; + } + let Some(rest) = val.get(start + id_len..start + id_len + 8) else { + continue; + }; + if rest[0].is_ascii_digit() + && rest[1].is_ascii_digit() + && alnum(rest[2]) + && rest[3..7].iter().all(u8::is_ascii_digit) + && (rest[7] == b' ' || rest[7] == 0) + { + return true; + } + } + } + false +} + +/// True when ExifTool's `@MakerNotes::Main` (pinned 13.59) resolves the note +/// to an entry *before* `MakerNoteSamsung1a`: everything from `MakerNoteApple` +/// (MakerNotes.pm:38) through `MakerNoteRicohText` (:942). `GetTagInfo` walks +/// the list in order and takes the first entry whose `Condition` holds, so +/// the value-typed entries this module emits are reachable only when every +/// one of these fails. Conditions are transcribed verbatim; entries whose +/// union is order-independent (a bare Make catch-all following narrower +/// tests of the same Make) are collapsed, which cannot change the OR. +/// +/// `val` must be the condition prefix - the first `min(size, 128)` bytes of +/// the value (Exif.pm:6717) - and `make`/`model` the trimmed DataMembers. +#[allow(clippy::too_many_lines)] +fn claimed_before_samsung1a(make: &str, model: &str, val: &[u8]) -> bool { + // MakerNoteApple: $$valPt =~ /^Apple iOS\0/ + if val.starts_with(b"Apple iOS\0") { + return true; + } + // MakerNoteNikon: $$valPt=~/^Nikon\x00\x02/; MakerNoteNikon2: /^Nikon\x00\x01/ + if val.starts_with(b"Nikon\x00\x02") || val.starts_with(b"Nikon\x00\x01") { + return true; + } + // MakerNoteCanon: $$self{Make} =~ /^Canon/ + if make.starts_with("Canon") { + return true; + } + // MakerNoteCasio ($$self{Make}=~/^CASIO/ and $$valPt!~/^(QVC|DCI)\0/) and + // MakerNoteCasio2 ($$valPt =~ /^(QVC|DCI)\0/) jointly claim either way. + if make.starts_with("CASIO") || val.starts_with(b"QVC\0") || val.starts_with(b"DCI\0") { + return true; + } + // MakerNoteDJIInfo: $$valPt =~ /^\[ae_dbg_info:/ + if val.starts_with(b"[ae_dbg_info:") { + return true; + } + // MakerNoteDJI: $$self{Make} eq "DJI" and $$valPt !~ /^(...\@AMBA|DJI)/s + if make == "DJI" && !(val.len() >= 8 && &val[3..8] == b"@AMBA") && !val.starts_with(b"DJI") { + return true; + } + // MakerNoteFLIR: $$self{Make} =~ /^(FLIR Systems|Teledyne FLIR)/ + if make.starts_with("FLIR Systems") || make.starts_with("Teledyne FLIR") { + return true; + } + // MakerNoteFujiFilm: $$valPt =~ /^(FUJIFILM|GENERALE)/ + if val.starts_with(b"FUJIFILM") || val.starts_with(b"GENERALE") { + return true; + } + // MakerNoteGE: $$valPt =~ /^GE(\0\0|NIC\0)/; MakerNoteGE2: /^GE\x0c\0\0\0\x16\0\0\0/ + if any_prefix(val, &[b"GE\0\0", b"GENIC\0", b"GE\x0c\0\0\0\x16\0\0\0"]) { + return true; + } + // MakerNoteGoogle: $$valPt =~ /^HDRP[\x02\x03]/ + if val.starts_with(b"HDRP\x02") || val.starts_with(b"HDRP\x03") { + return true; + } + // MakerNoteHasselblad: $$self{Make} eq "Hasselblad" + if make == "Hasselblad" { + return true; + } + // MakerNoteHP: $$valPt =~ /^(Hewlett-Packard|Vivitar)/ + if val.starts_with(b"Hewlett-Packard") || val.starts_with(b"Vivitar") { + return true; + } + // MakerNoteHP2: $$valPt =~ /^610[\0-\4]/ + if val.len() >= 4 && val.starts_with(b"610") && val[3] <= 0x04 { + return true; + } + // MakerNoteHP4: $$valPt =~ /^IIII[\x04|\x05]\0/ (the class holds a literal '|') + if val.len() >= 6 + && val.starts_with(b"IIII") + && matches!(val[4], 0x04 | 0x05 | b'|') + && val[5] == 0 + { + return true; + } + // MakerNoteHP6: $$valPt =~ /^IIII\x06\0/ + if val.starts_with(b"IIII\x06\0") { + return true; + } + // MakerNoteISL: $$valPt =~ /^ISLMAKERNOTE000\0/ + if val.starts_with(b"ISLMAKERNOTE000\0") { + return true; + } + // MakerNoteJVC: $$valPt=~/^JVC / + if val.starts_with(b"JVC ") { + return true; + } + // MakerNoteJVCText: $$self{Make}=~/^(JVC|Victor)/ and $$valPt=~/^VER:/ + if (make.starts_with("JVC") || make.starts_with("Victor")) && val.starts_with(b"VER:") { + return true; + } + // MakerNoteKodak1a (/^KDK INFO/) and MakerNoteKodak1b (/^KDK/), both + // gated on $$self{Make}=~/^EASTMAN KODAK/: the 1b prefix covers 1a. + if make.starts_with("EASTMAN KODAK") && val.starts_with(b"KDK") { + return true; + } + // MakerNoteKodak2: $$valPt =~ /^.{8}Eastman Kodak/s or + // $$valPt =~ /^\x01\0[\0\x01]\0\0\0\x04\0[a-zA-Z]{4}/ + if val.len() >= 21 && &val[8..21] == b"Eastman Kodak" { + return true; + } + if val.len() >= 12 + && val[0] == 0x01 + && val[1] == 0 + && (val[2] == 0 || val[2] == 0x01) + && val[3] == 0 + && val[4] == 0 + && val[5] == 0 + && val[6] == 0x04 + && val[7] == 0 + && val[8..12].iter().all(u8::is_ascii_alphabetic) + { + return true; + } + let mm_ii_aoc = val.starts_with(b"MM") || val.starts_with(b"II") || val.starts_with(b"AOC"); + // MakerNoteKodak3: /^EASTMAN KODAK/, $$valPt =~ /^(?!MM|II).{12}\x07/s + // and !~ /^(MM|II|AOC)/ (the lookahead is subsumed by the negative) + if make.starts_with("EASTMAN KODAK") && val.len() >= 13 && val[12] == 0x07 && !mm_ii_aoc { + return true; + } + // MakerNoteKodak4: /^Eastman Kodak/, $$valPt =~ /^.{41}JPG/s, !^(MM|II|AOC) + if make.starts_with("Eastman Kodak") && val.len() >= 44 && &val[41..44] == b"JPG" && !mm_ii_aoc + { + return true; + } + // MakerNoteKodak5: /^EASTMAN KODAK/ and (Model CX-list or the byte probe) + if make.starts_with("EASTMAN KODAK") + && (["CX4200", "CX4230", "CX4300", "CX4310", "CX6200", "CX6230"] + .iter() + .any(|cx| model.contains(cx)) + || (val.len() >= 4 + && val[0] == 0 + && matches!( + (val[1], val[2]), + (0x1a, 0x18) | (0x3a, 0x08) | (0x59, 0xf8) | (0x14, 0x80) + ) + && val[3] == 0)) + { + return true; + } + // MakerNoteKodak6a (Model DX3215) / MakerNoteKodak6b (Model DX3700) + if make.starts_with("EASTMAN KODAK") && (model.contains("DX3215") || model.contains("DX3700")) { + return true; + } + let kodak_make = make.to_ascii_lowercase().contains("kodak"); + // MakerNoteKodak7: /Kodak/i and the serial-number probe + if kodak_make && kodak7_serial(val) { + return true; + } + // MakerNoteKodak8a: /Kodak/i and either IFD-entry probe + if kodak_make + && ((val.len() >= 8 + && val[0] == 0 + && (0x02..=0x7f).contains(&val[1]) + && val[4] == 0 + && (0x01..=0x0c).contains(&val[5]) + && val[6] == 0 + && val[7] == 0) + || (val.len() >= 10 + && (0x02..=0x7f).contains(&val[0]) + && val[1] == 0 + && (0x01..=0x0c).contains(&val[4]) + && val[5] == 0 + && val[8] == 0 + && val[9] == 0)) + { + return true; + } + // MakerNoteKodak8b: /Kodak/i and /^MM\0\x2a\0\0\0\x08\0.\0\0/ (no /s: + // the wildcard byte may not be "\n") + if kodak_make + && val.len() >= 12 + && val.starts_with(b"MM\0\x2a\0\0\0\x08\0") + && val[9] != b'\n' + && val[10] == 0 + && val[11] == 0 + { + return true; + } + // MakerNoteKodak8c: /Kodak/i and /^(MM\0\x2a\0\0\0\x08|II\x2a\0\x08\0\0\0)/ + if kodak_make + && (val.starts_with(b"MM\0\x2a\0\0\0\x08") || val.starts_with(b"II\x2a\0\x08\0\0\0")) + { + return true; + } + // MakerNoteKodak9: m{^IIII[\x02\x03]\0.{14}\d{4}/\d{2}/\d{2} }s + if val.len() >= 31 + && val.starts_with(b"IIII") + && matches!(val[4], 0x02 | 0x03) + && val[5] == 0 + && val[20..24].iter().all(u8::is_ascii_digit) + && val[24] == b'/' + && val[25..27].iter().all(u8::is_ascii_digit) + && val[27] == b'/' + && val[28..30].iter().all(u8::is_ascii_digit) + && val[30] == b' ' + { + return true; + } + // MakerNoteKodak10: /Kodak/i and /^(MM\0[\x02-\x7f]|II[\x02-\x7f]\0)/ + if kodak_make + && val.len() >= 4 + && ((val.starts_with(b"MM\0") && (0x02..=0x7f).contains(&val[3])) + || (val.starts_with(b"II") && (0x02..=0x7f).contains(&val[2]) && val[3] == 0)) + { + return true; + } + // MakerNoteKodak11 and MakerNoteKodak12 key on Model =~ /(Kodak|PixPro)/i + let kodak_model = { + let lower = model.to_ascii_lowercase(); + lower.contains("kodak") || lower.contains("pixpro") + }; + if kodak_model + && val.len() >= 12 + && ((val.starts_with(b"II\x2a\0\x08\0\0\0") && val[9] == 0 && val[10] == 0 && val[11] == 0) + || (val.starts_with(b"MM\0\x2a\0\0\0\x08") + && val[8] == 0 + && val[9] == 0 + && val[10] == 0)) + { + return true; + } + // MakerNoteKodakUnknown: $$self{Make}=~/Kodak/i and $$valPt!~/^AOC\0/ + if kodak_make && !val.starts_with(b"AOC\0") { + return true; + } + // MakerNoteKyocera: $$valPt =~ /^KYOCERA/ + if val.starts_with(b"KYOCERA") { + return true; + } + // MakerNoteMinolta (Make and !^(MINOL|CAMER|MLY0|KC|\+M\+M|\xd7)) plus the + // MakerNoteMinolta3 catch-all on the same /^(Konica Minolta|Minolta)/i. + if ci_starts_with(make, "Konica Minolta") || ci_starts_with(make, "Minolta") { + return true; + } + // MakerNoteMinolta2: $$valPt =~ /^(MINOL|CAMER)\0/ + if val.starts_with(b"MINOL\0") || val.starts_with(b"CAMER\0") { + return true; + } + // MakerNoteMotorola: $$valPt=~/^MOT\0/ + if val.starts_with(b"MOT\0") { + return true; + } + // MakerNoteNikon3: $$self{Make}=~/^NIKON/i + if ci_starts_with(make, "NIKON") { + return true; + } + // MakerNoteNintendo: $$self{Make} eq "Nintendo" + if make == "Nintendo" { + return true; + } + // MakerNoteOlympus (/^(OLYMP|EPSON)\0/), MakerNoteOlympus2 (/^OLYMPUS\0/), + // MakerNoteOlympus3 (/^OM SYSTEM\0/) + if any_prefix(val, &[b"OLYMP\0", b"EPSON\0", b"OLYMPUS\0", b"OM SYSTEM\0"]) { + return true; + } + // MakerNoteLeica: $$self{Make} eq "LEICA" + if make == "LEICA" { + return true; + } + let leica_ag = make.starts_with("Leica Camera AG"); + // MakerNoteLeica2: Make and $$valPt =~ /^LEICA\0\0\0/ + if leica_ag && val.starts_with(b"LEICA\0\0\0") { + return true; + } + // MakerNoteLeica3: Make, $$valPt !~ /^LEICA/, Model ne S2 / M (Typ 240) + if leica_ag && !val.starts_with(b"LEICA") && model != "S2" && model != "LEICA M (Typ 240)" { + return true; + } + // MakerNoteLeica4: Make and $$valPt =~ /^LEICA0/ (a literal '0': the + // M9/M-Monochrom header is "LEICA0\x03\0") + if leica_ag && val.starts_with(b"LEICA0") { + return true; + } + // MakerNoteLeica5: $$valPt =~ /^LEICA\0[\x01\x04\x05\x06\x07\x10\x1a]\0/ + if val.len() >= 8 + && val.starts_with(b"LEICA\0") + && matches!(val[6], 0x01 | 0x04..=0x07 | 0x10 | 0x1a) + && val[7] == 0 + { + return true; + } + // MakerNoteLeica6: Make eq 'Leica Camera AG' and the three Model names + if make == "Leica Camera AG" + && matches!(model, "S2" | "LEICA M (Typ 240)" | "LEICA S (Typ 006)") + { + return true; + } + // MakerNoteLeica7: $$valPt =~ /^LEICA\0\x02\xff/ + if val.starts_with(b"LEICA\0\x02\xff") { + return true; + } + // MakerNoteLeica8: $$valPt =~ /^LEICA\0[\x08\x09\x0a]\0/ + if val.len() >= 8 && val.starts_with(b"LEICA\0") && matches!(val[6], 0x08..=0x0a) && val[7] == 0 + { + return true; + } + // MakerNoteLeica9: Make and $$valPt =~ /^LEICA\0\x02\0/ + if leica_ag && val.starts_with(b"LEICA\0\x02\0") { + return true; + } + // MakerNoteLeica10: $$valPt =~ /^LEICA CAMERA AG\0/ + if val.starts_with(b"LEICA CAMERA AG\0") { + return true; + } + // MakerNotePanasonic (Model ne "DC-FT7") and MakerNotePanasonic3 (no + // Model test) claim every /^Panasonic/ value between them. + if val.starts_with(b"Panasonic") { + return true; + } + // MakerNotePanasonic2: $$self{Make}=~/^Panasonic/ and $$valPt=~/^MKE/ + if make.starts_with("Panasonic") && val.starts_with(b"MKE") { + return true; + } + // MakerNotePentax: /^AOC\0/ and Model !~ /^PENTAX Optio ?[34]30RS\s*$/ + // (trailing whitespace is already stripped from the member) + if val.starts_with(b"AOC\0") + && !matches!( + model, + "PENTAX Optio 330RS" | "PENTAX Optio330RS" | "PENTAX Optio 430RS" | "PENTAX Optio430RS" + ) + { + return true; + } + // MakerNotePentax2 (/^Asahi/ and !^AOC\0) plus the MakerNotePentax3 + // catch-all on the same /^Asahi/. + if make.starts_with("Asahi") { + return true; + } + // MakerNotePentax4: $$self{Make}=~/^PENTAX/ and $$valPt=~/^\d{3}/ + if make.starts_with("PENTAX") && val.len() >= 3 && val[..3].iter().all(u8::is_ascii_digit) { + return true; + } + // MakerNotePentax5: $$valPt=~/^PENTAX \0/ + if val.starts_with(b"PENTAX \0") { + return true; + } + // MakerNotePentax6: $$valPt=~/^S1\0{6}\x0c\0{3}/ + if val.starts_with(b"S1\0\0\0\0\0\0\x0c\0\0\0") { + return true; + } + // MakerNotePhaseOne: $$valPt =~ /^(IIII.waR|MMMMRaw.)/s + if val.len() >= 8 + && ((val.starts_with(b"IIII") && &val[5..8] == b"waR") || val.starts_with(b"MMMMRaw")) + { + return true; + } + // MakerNoteReconyxHyperFire: $$valPt =~ /^\x01\xf1([\x02\x03]\x00)?/ and + // ($1 or $$self{Make} eq "RECONYX") + if val.starts_with(b"\x01\xf1\x02\x00") + || val.starts_with(b"\x01\xf1\x03\x00") + || (val.starts_with(b"\x01\xf1") && make == "RECONYX") + { + return true; + } + // MakerNoteReconyxUltraFire / HyperFire2 / MicroFire / HyperFire4K + if any_prefix( + val, + &[ + b"RECONYXUF\0", + b"RECONYXH2\0", + b"RECONYXMF\0", + b"RECONYXHF4K\0", + ], + ) { + return true; + } + // MakerNoteRicohPentax: $$valPt=~/^RICOH\0(II|MM)/ + if val.starts_with(b"RICOH\0II") || val.starts_with(b"RICOH\0MM") { + return true; + } + // MakerNoteRicohText's bare /^RICOH/ catch-all claims every remaining + // RICOH-made note; a "PENTAX RICOH" Make reaches only MakerNoteRicoh and + // MakerNoteRicoh2, whose conditions are tested verbatim below. + if make.starts_with("RICOH") { + return true; + } + if make.starts_with("PENTAX RICOH") { + // The /s-mode probe shared by MakerNoteRicoh (negated) and + // MakerNoteRicoh2: /^(MM\0\x2a\0\0\0\x08\0.\0\0|II\x2a\0\x08\0\0\0.\0\0\0)/s + let ricoh2_probe = (val.len() >= 12 + && val.starts_with(b"MM\0\x2a\0\0\0\x08\0") + && val[10] == 0 + && val[11] == 0) + || (val.len() >= 12 + && val.starts_with(b"II\x2a\0\x08\0\0\0") + && val[9] == 0 + && val[10] == 0 + && val[11] == 0); + // MakerNoteRicoh: /^(Ricoh| |MM\0\x2a|II\x2a\0)/i, not the + // probe, Model ne 'RICOH WG-M1' + if (ci_val_prefix(val, b"Ricoh") + || val.starts_with(b" ") + || ci_val_prefix(val, b"MM\0\x2a") + || ci_val_prefix(val, b"II\x2a\0")) + && !ricoh2_probe + && model != "RICOH WG-M1" + { + return true; + } + // MakerNoteRicoh2: Model eq 'RICOH WG-M1' or the probe + if model == "RICOH WG-M1" || ricoh2_probe { + return true; + } + } + false +} + +/// True when an entry *between* `MakerNoteSamsung1b` and the +/// `MakerNoteUnknown*` fallbacks claims the note (MakerNotes.pm:966-1101: +/// Samsung2, the Sanyo and Sony families, Sigma). `MakerNoteSamsung1b` +/// itself is handled by the caller's STMN branch. +fn claimed_between_samsung1b_and_unknown(make: &str, model: &str, val: &[u8]) -> bool { + // MakerNoteSamsung2: uc $$self{Make} eq 'SAMSUNG' and ($$self{TIFF_TYPE} + // eq 'SRW' or $$valPt=~/^(\0.\0\x01\0\x07\0{3}\x04|.\0\x01\0\x07\0\x04\0{3})0100/s). + // The TIFF_TYPE arm is unavailable here (no container context reaches + // this helper), but an SRW's EXIF-format maker note is a binary IFD that + // can never satisfy the text/LSI1 fallbacks below, so the byte probe + // alone is exact for every value those fallbacks could otherwise take. + if make.eq_ignore_ascii_case("SAMSUNG") + && val.len() >= 14 + && &val[10..14] == b"0100" + && ((val[0] == 0 + && val[2] == 0 + && val[3] == 0x01 + && val[4] == 0 + && val[5] == 0x07 + && val[6] == 0 + && val[7] == 0 + && val[8] == 0 + && val[9] == 0x04) + || (val[1] == 0 + && val[2] == 0x01 + && val[3] == 0 + && val[4] == 0x07 + && val[5] == 0 + && val[6] == 0x04 + && val[7] == 0 + && val[8] == 0 + && val[9] == 0)) + { + return true; + } + // MakerNoteSanyo / SanyoC4 / SanyoPatch: SanyoPatch is a bare + // $$self{Make}=~/^SANYO/ catch-all, so the Make alone decides. + if make.starts_with("SANYO") { + return true; + } + // MakerNoteSigma: $$self{Make}=~/^(SIGMA|FOVEON)/i + if ci_starts_with(make, "SIGMA") || ci_starts_with(make, "FOVEON") { + return true; + } + // MakerNoteSony: /^(SONY (DSC|CAM|MOBILE)|\0\0SONY PIC\0|VHAB \0)/, + // MakerNoteSony2 (/^SONY PI\0/), MakerNoteSony3 (/^(PREMI)\0/), + // MakerNoteSony4 (/^SONY PIC\0/) + if any_prefix( + val, + &[ + b"SONY DSC", + b"SONY CAM", + b"SONY MOBILE", + b"\0\0SONY PIC\0", + b"VHAB \0", + b"SONY PI\0", + b"PREMI\0", + b"SONY PIC\0", + ], + ) { + return true; + } + // MakerNoteSony5 plus the MakerNoteSonySRF catch-all: /^SONY/ claims + // regardless, and Sony5's Hasselblad-rebadge arm adds + // (Make ^HASSELBLAD, Model ^(HV|Stellar|Lusso|Lunar), val !^\x01\x00). + if make.starts_with("SONY") { + return true; + } + if make.starts_with("HASSELBLAD") + && (model.starts_with("HV") + || model.starts_with("Stellar") + || model.starts_with("Lusso") + || model.starts_with("Lunar")) + && !val.starts_with(b"\x01\x00") + { + return true; + } + // MakerNoteSonyEricsson: $$valPt =~ /^SEMC MS\0/ + if val.starts_with(b"SEMC MS\0") { + return true; + } + false +} + +/// `MakerNoteUnknownText`'s Condition, +/// `$$valPt =~ /^[\x09\x0d\x0a\x20-\x7e]+\0*$/` (MakerNotes.pm:1102-1108), +/// applied as Perl applies it: with no `/m`, `$` also matches just before a +/// string-final `"\n"`, so `text NULs "\n"` passes too. Nothing is +/// NUL-trimmed before the test. +fn unknown_text_condition(prefix: &[u8]) -> bool { + fn text_then_nuls(bytes: &[u8]) -> bool { + let text_len = bytes + .iter() + .take_while(|byte| matches!(**byte, b'\t' | b'\n' | b'\r' | 0x20..=0x7e)) + .count(); + text_len > 0 && bytes[text_len..].iter().all(|byte| *byte == 0) + } + text_then_nuls(prefix) + || matches!(prefix.split_last(), Some((b'\n', body)) if text_then_nuls(body)) +} + +/// Applies ExifTool's condition-specific names to the MakerNote (0x927C) +/// values it stores as plain values rather than parsed subdirectories: +/// `MakerNoteSamsung1a`, `MakerNoteUnknownText` and `MakerNoteUnknownBinary` +/// (MakerNotes.pm, pinned 13.59). +/// +/// ExifTool resolves the name by walking `@MakerNotes::Main` in order and +/// taking the first entry whose `Condition` holds; a Condition sees +/// `$$self{Make}`/`$$self{Model}` and only the first `min(size, 128)` bytes +/// of the value (Exif.pm:6717). The three names above are reachable only +/// when every maker-specific entry before them fails, so when a preceding +/// entry claims the note this returns `None` - oxidex may lack that maker's +/// parser, and a `MakerNoteUnknown*` name for a claimed note would be a +/// wrong name, not a fallback. +fn special_makernote_value( + resolved_name: &str, + data: &[u8], + make: &str, + model: &str, +) -> Option<(String, TagValue)> { + use crate::parsers::tiff::makernotes::samsung::stmn; + + let condition_prefix = &data[..data.len().min(128)]; + + if claimed_before_samsung1a(make, model, condition_prefix) { + return None; + } + + // MakerNoteSamsung1a (`/^STMN\d{3}.\0{4}/s`) stores the note as a bare + // binary value; MakerNoteSamsung1b (`/^STMN\d{3}/`) is a subdirectory + // the second pass parses, and it must not fall through to the fallbacks. + if stmn::is_stmn(condition_prefix) { + if stmn::is_binary_only(condition_prefix) { + return Some(( + contextual_tag_name(resolved_name, "MakerNoteSamsung1a"), + TagValue::new_binary(data.to_vec()), + )); + } + return None; + } + + if claimed_between_samsung1b_and_unknown(make, model, condition_prefix) { + return None; + } + + // MakerNoteUnknownText. The Condition sees only the 128-byte prefix and + // nothing is NUL-trimmed anywhere: its ValueConv + // `length($val) > 64 ? \$val : $val` measures the FULL untrimmed value, + // so a short text note NUL-padded past 64 bytes is reported as binary + // with the padded length. + if unknown_text_condition(condition_prefix) { + let name = contextual_tag_name(resolved_name, "MakerNoteUnknownText"); + if data.len() > 64 { + return Some((name, TagValue::new_binary(data.to_vec()))); + } + // The stored value is the untrimmed text, which the exiftool + // application prints through its output filter (exiftool:3007-3009): + // \x01-\x1f and \x7f become '.', NULs are deleted, trailing spaces + // are trimmed. oxidex stores display strings, so the same filter is + // fused here; a value this short (<= 64 bytes) was covered by the + // condition in full, so every byte is ASCII and the mapping is total. + let rendered: String = data + .iter() + .filter_map(|byte| match *byte { + 0 => None, + 0x01..=0x1f | 0x7f => Some('.'), + printable => Some(printable as char), + }) + .collect(); + return Some(( + name, + TagValue::new_string(rendered.trim_end_matches(' ').to_string()), + )); + } + + // MakerNoteUnknownBinary: $$valPt =~ /^LSI1\0/ (SilverFast). + if condition_prefix.starts_with(b"LSI1\0") { + return Some(( + contextual_tag_name(resolved_name, "MakerNoteUnknownBinary"), + TagValue::new_binary(data.to_vec()), + )); + } + None +} + +#[cfg(test)] +mod makernote_fallback_tests { + use super::*; + + const RESOLVED: &str = "ExifIFD:MakerNote"; + + fn fallback(data: &[u8], make: &str, model: &str) -> Option<(String, TagValue)> { + special_makernote_value(RESOLVED, data, make, model) + } + + /// Exif.pm:6717 hands the Condition only the first min(size, 128) bytes, + /// so binary garbage past byte 128 cannot defeat the text match. + #[test] + fn text_condition_examines_only_the_first_128_bytes() { + let mut data = vec![b'A'; 128]; + data.extend_from_slice(&[0xFF, 0x00, 0x13]); + let (name, value) = fallback(&data, "", "").expect("prefix is pure text"); + assert_eq!(name, "ExifIFD:MakerNoteUnknownText"); + assert!( + matches!(value, TagValue::Binary(ref bytes) if bytes.len() == 131), + "the >64 branch must carry the FULL value" + ); + } + + /// The `length($val) > 64` split measures the untrimmed value: 14 text + /// bytes NUL-padded to 70 report as 70 bytes of binary, never as the + /// trimmed string (SamsungDigimaxA4.jpg does this with 460 bytes). + #[test] + fn text_binary_split_measures_the_untrimmed_value() { + let mut data = b"Unknown Format".to_vec(); + data.resize(70, 0); + let (name, value) = fallback(&data, "SAMSUNG TECHWIN CO.", "").expect("text plus NULs"); + assert_eq!(name, "ExifIFD:MakerNoteUnknownText"); + assert_eq!(value, TagValue::new_binary(data)); + } + + /// A value at or under 64 bytes stays a string, rendered as the exiftool + /// application renders it (NULs deleted, trailing spaces trimmed). + #[test] + fn short_text_value_renders_like_the_exiftool_app() { + let mut data = b"FINE".to_vec(); + data.resize(10, 0); + let (_, value) = fallback(&data, "Samsung", "SPH-A940").expect("short text"); + assert_eq!(value.as_string(), Some("FINE")); + } + + /// `/^[\x09\x0d\x0a\x20-\x7e]+\0*$/` rejects text resuming after a NUL. + #[test] + fn nul_interrupted_text_is_not_text() { + assert_eq!(fallback(b"AB\0CD", "", ""), None); + } + + /// Perl's `$` (no /m) also matches just before a string-final "\n". + #[test] + fn trailing_newline_after_nuls_still_matches() { + let (name, _) = fallback(b"AB\0\0\n", "", "").expect("Perl-$ newline form"); + assert_eq!(name, "ExifIFD:MakerNoteUnknownText"); + } + + /// @MakerNotes::Main entries preceding the fallbacks claim the note + /// first: MakerNoteRicohText takes any RICOH-made note, + /// MakerNoteJVCText takes a JVC "VER:" note, MakerNoteCanon takes + /// everything Canon-made. No MakerNoteUnknown* may fire for them, and + /// oxidex emits nothing in their place. + #[test] + fn preceding_maker_conditions_claim_the_note() { + assert_eq!( + fallback(b"Text note\0\0", "RICOH IMAGING COMPANY, LTD.", ""), + None + ); + assert_eq!(fallback(b"VER:1.0\0", "JVC", "GR-D230"), None); + assert_eq!(fallback(b"LSI1\0abc", "Canon", "EOS"), None); + } + + /// Makes that defeat their maker's Condition fall through to the + /// fallbacks: "FS-Nikon" fails /^NIKON/i (NikonLS-50.jpg), and a note + /// starting "DJI" fails MakerNoteDJI's `$$valPt !~ /^(...\@AMBA|DJI)/s` + /// (DJI_M3T.jpg). + #[test] + fn non_claimed_makes_reach_the_fallbacks() { + let (name, _) = fallback(b"LSI1\0data", "FS-Nikon", "LS-50").expect("LSI1 note"); + assert_eq!(name, "ExifIFD:MakerNoteUnknownBinary"); + + let (name, value) = fallback(b"DJI MakerNotes\0\0", "DJI", "M3T").expect("DJI text note"); + assert_eq!(name, "ExifIFD:MakerNoteUnknownText"); + assert_eq!(value.as_string(), Some("DJI MakerNotes")); + } + + /// STMN with a zeroed PreviewImageStart is MakerNoteSamsung1a; with a + /// nonzero one it is the MakerNoteSamsung1b subdirectory, which the + /// second pass parses - not a fallback value. + #[test] + fn stmn_splits_between_samsung1a_and_samsung1b() { + let mut binary_only = b"STMN010\0".to_vec(); + binary_only.extend_from_slice(&[0, 0, 0, 0, 0xAA]); + let (name, _) = fallback(&binary_only, "SAMSUNG", "").expect("1a note"); + assert_eq!(name, "ExifIFD:MakerNoteSamsung1a"); + + let mut with_preview = b"STMN010\0".to_vec(); + with_preview.extend_from_slice(&[1, 2, 3, 4, 0xAA]); + assert_eq!(fallback(&with_preview, "SAMSUNG", ""), None); + } +} + +fn push_u16(out: &mut Vec, value: u16, byte_order: ByteOrder) { + let bytes = match byte_order { + ByteOrder::LittleEndian => value.to_le_bytes(), + ByteOrder::BigEndian => value.to_be_bytes(), + }; + out.extend_from_slice(&bytes); +} + +fn push_u32(out: &mut Vec, value: u32, byte_order: ByteOrder) { + let bytes = match byte_order { + ByteOrder::LittleEndian => value.to_le_bytes(), + ByteOrder::BigEndian => value.to_be_bytes(), + }; + out.extend_from_slice(&bytes); +} + /// Compression (0x0103) - in IFD1 this describes the thumbnail encoding. const TAG_COMPRESSION: u16 = 0x0103; @@ -1221,6 +2031,258 @@ fn read_unsigned_field( u64::try_from(value).ok() } +fn read_unsigned_fields( + raw: &[u8], + field_type: u16, + count: u32, + byte_order: ByteOrder, +) -> Option> { + let width = match field_type { + 3 => 2usize, + 4 => 4usize, + _ => return None, + }; + let count = usize::try_from(count).ok()?; + let bytes = raw.get(..count.checked_mul(width)?)?; + let values = match (field_type, byte_order) { + (3, ByteOrder::LittleEndian) => bytes + .chunks_exact(2) + .map(|value| u16::from_le_bytes([value[0], value[1]]) as u64) + .collect(), + (3, ByteOrder::BigEndian) => bytes + .chunks_exact(2) + .map(|value| u16::from_be_bytes([value[0], value[1]]) as u64) + .collect(), + (4, ByteOrder::LittleEndian) => bytes + .chunks_exact(4) + .map(|value| u32::from_le_bytes([value[0], value[1], value[2], value[3]]) as u64) + .collect(), + (4, ByteOrder::BigEndian) => bytes + .chunks_exact(4) + .map(|value| u32::from_be_bytes([value[0], value[1], value[2], value[3]]) as u64) + .collect(), + _ => return None, + }; + Some(values) +} + +/// ImageWidth (0x0100), ImageHeight (0x0101), BitsPerSample (0x0102), +/// PhotometricInterpretation (0x0106), Orientation (0x0112), +/// SamplesPerPixel (0x0115) and PlanarConfiguration (0x011C), the IFD1 +/// fields `RebuildTIFF` consumes. +const TAG_IMAGE_WIDTH: u16 = 0x0100; +const TAG_IMAGE_HEIGHT: u16 = 0x0101; +const TAG_BITS_PER_SAMPLE: u16 = 0x0102; +const TAG_PHOTOMETRIC_INTERPRETATION: u16 = 0x0106; +const TAG_ORIENTATION: u16 = 0x0112; +const TAG_SAMPLES_PER_PIXEL: u16 = 0x0115; +const TAG_PLANAR_CONFIGURATION: u16 = 0x011C; + +/// A value slot in the rebuilt directory, carrying its TIFF type. +enum RebuiltValue { + /// int16u (TIFF SHORT, type 3); may hold several values (BitsPerSample). + Short(Vec), + /// int32u (TIFF LONG, type 4). + Long(u32), + /// rational64u (TIFF RATIONAL, type 5) as numerator/denominator. + Rational(u32, u32), +} + +/// Rebuilds an uncompressed strip-based IFD1 image as ExifTool's +/// self-contained `ThumbnailTIFF`/`PreviewTIFF`, replicating `RebuildTIFF` +/// and `GenerateTIFF` (Exif.pm:6139-6208 and 6101-6130, pinned 13.59) byte +/// for byte: +/// +/// * gates: `SubfileType == 1` (reduced-resolution image) and +/// `Compression == 1` (uncompressed); ImageWidth, ImageHeight, +/// BitsPerSample, PhotometricInterpretation, StripOffsets, +/// SamplesPerPixel, RowsPerStrip and StripByteCounts must all be present +/// in this IFD, while PlanarConfiguration and Orientation default to 1; +/// * validation: every strip's byte count must equal +/// `rowBytes * RowsPerStrip`, where `rowBytes` is +/// `sum(ImageWidth * int((bits + 7) / 8))` over the BitsPerSample values, +/// and every strip must read back in full; +/// * layout: a fixed 15-entry directory (0x00FE..0x0128) in this IFD's byte +/// order and ascending tag order, SubfileType rewritten to 0, a single +/// strip (`RowsPerStrip = ImageHeight`, +/// `StripByteCounts = ImageHeight * rowBytes`), XResolution/YResolution 72 +/// and ResolutionUnit inches, each tag in its `Exif::Main` Writable format +/// (the conditional-list tags 0x0111/0x0117 fall back to int32u), +/// out-of-line values appended after the directory in entry order, and the +/// concatenated strip data last; +/// * naming: `PreviewTIFF` when ImageWidth > 256, else `ThumbnailTIFF` +/// (Exif.pm:6199-6203). +fn rebuild_thumbnail_tiff( + reader: &dyn FileReader, + entries: &[(u16, u16, u32, std::borrow::Cow<'_, [u8]>)], + byte_order: ByteOrder, +) -> Option<(&'static str, Vec)> { + let field = |tag: u16| entries.iter().find(|entry| entry.0 == tag); + let scalar = |tag: u16| { + let entry = field(tag)?; + read_unsigned_field(entry.3.as_ref(), entry.1, entry.2, entry.0, byte_order) + }; + let vector = |tag: u16| { + let entry = field(tag)?; + read_unsigned_fields(entry.3.as_ref(), entry.1, entry.2, byte_order) + }; + + // RebuildTIFF only processes a SubfileType == 1 (reduced-resolution) + // directory whose Compression is 1 (uncompressed). + if scalar(TAG_SUBFILE_TYPE)? != 1 || scalar(TAG_COMPRESSION)? != 1 { + return None; + } + let width = scalar(TAG_IMAGE_WIDTH)?; + let height = scalar(TAG_IMAGE_HEIGHT)?; + let photometric = scalar(TAG_PHOTOMETRIC_INTERPRETATION)?; + let samples_per_pixel = scalar(TAG_SAMPLES_PER_PIXEL)?; + let rows_per_strip = scalar(TAG_ROWS_PER_STRIP)?; + let bits = vector(TAG_BITS_PER_SAMPLE)?; + let offsets = vector(TAG_STRIP_OFFSETS)?; + let counts = vector(TAG_STRIP_BYTE_COUNTS)?; + let planar_configuration = scalar(TAG_PLANAR_CONFIGURATION).unwrap_or(1); + let orientation = scalar(TAG_ORIENTATION).unwrap_or(1); + if bits.is_empty() { + return None; + } + + // $rowBytes += $w * int(($_+7)/8) foreach @bits; + let mut row_bytes: u64 = 0; + for bit in &bits { + row_bytes = row_bytes.checked_add(width.checked_mul(bit.checked_add(7)? / 8)?)?; + } + let expected_strip_len = row_bytes.checked_mul(rows_per_strip)?; + + // Read and concatenate the strips; any short or failed read aborts, as + // ExtractBinary's failure does. + let mut data = Vec::new(); + for (index, &offset) in offsets.iter().enumerate() { + if *counts.get(index)? != expected_strip_len { + return None; + } + let len = usize::try_from(expected_strip_len).ok()?; + let strip = reader.read(offset, len).ok()?; + if strip.len() != len { + return None; + } + data.extend_from_slice(strip); + } + + // GenerateTIFF's fixed entry set, ascending tag order. + let directory: [(u16, RebuiltValue); 15] = [ + (TAG_SUBFILE_TYPE, RebuiltValue::Long(0)), + (TAG_IMAGE_WIDTH, RebuiltValue::Long(width as u32)), + (TAG_IMAGE_HEIGHT, RebuiltValue::Long(height as u32)), + ( + TAG_BITS_PER_SAMPLE, + RebuiltValue::Short(bits.iter().map(|bit| *bit as u16).collect()), + ), + (TAG_COMPRESSION, RebuiltValue::Short(vec![1])), + ( + TAG_PHOTOMETRIC_INTERPRETATION, + RebuiltValue::Short(vec![photometric as u16]), + ), + (TAG_STRIP_OFFSETS, RebuiltValue::Long(0)), // fixed up below + ( + TAG_ORIENTATION, + RebuiltValue::Short(vec![orientation as u16]), + ), + ( + TAG_SAMPLES_PER_PIXEL, + RebuiltValue::Short(vec![samples_per_pixel as u16]), + ), + (TAG_ROWS_PER_STRIP, RebuiltValue::Long(height as u32)), + ( + TAG_STRIP_BYTE_COUNTS, + RebuiltValue::Long(height.checked_mul(row_bytes)? as u32), + ), + (0x011A, RebuiltValue::Rational(72, 1)), // XResolution + (0x011B, RebuiltValue::Rational(72, 1)), // YResolution + ( + TAG_PLANAR_CONFIGURATION, + RebuiltValue::Short(vec![planar_configuration as u16]), + ), + (0x0128, RebuiltValue::Short(vec![2])), // ResolutionUnit = inches + ]; + + // Header (10 bytes) + entries + the next-IFD terminator. + let directory_end = 10 + 12 * directory.len() + 4; + let mut out = Vec::with_capacity(directory_end); + out.extend_from_slice(match byte_order { + ByteOrder::LittleEndian => b"II", + ByteOrder::BigEndian => b"MM", + }); + push_u16(&mut out, 42, byte_order); + push_u32(&mut out, 8, byte_order); + push_u16(&mut out, directory.len() as u16, byte_order); + + let mut out_of_line = Vec::new(); + let mut strip_offset_position = None; + for (tag, value) in &directory { + push_u16(&mut out, *tag, byte_order); + let (tiff_type, size, bytes) = match value { + RebuiltValue::Short(items) => { + let mut encoded = Vec::with_capacity(items.len() * 2); + for item in items { + push_u16(&mut encoded, *item, byte_order); + } + (3u16, 2usize, encoded) + } + RebuiltValue::Long(item) => { + let mut encoded = Vec::with_capacity(4); + push_u32(&mut encoded, *item, byte_order); + (4, 4, encoded) + } + RebuiltValue::Rational(numerator, denominator) => { + let mut encoded = Vec::with_capacity(8); + push_u32(&mut encoded, *numerator, byte_order); + push_u32(&mut encoded, *denominator, byte_order); + (5, 8, encoded) + } + }; + push_u16(&mut out, tiff_type, byte_order); + push_u32(&mut out, (bytes.len() / size) as u32, byte_order); + if *tag == TAG_STRIP_OFFSETS { + strip_offset_position = Some(out.len()); + } + if bytes.len() > 4 { + push_u32( + &mut out, + (directory_end + out_of_line.len()) as u32, + byte_order, + ); + out_of_line.extend_from_slice(&bytes); + } else { + // Inline values are right-padded with NULs regardless of byte + // order, as Set-then-pad does in GenerateTIFF. + out.extend_from_slice(&bytes); + out.resize(out.len() + 4 - bytes.len(), 0); + } + } + push_u32(&mut out, 0, byte_order); // no IFD1 in the rebuilt file + + // StripOffsets points at the strip data, which follows the out-of-line + // values. + let data_offset = (directory_end + out_of_line.len()) as u32; + let position = strip_offset_position?; + let patched = match byte_order { + ByteOrder::LittleEndian => data_offset.to_le_bytes(), + ByteOrder::BigEndian => data_offset.to_be_bytes(), + }; + out[position..position + 4].copy_from_slice(&patched); + + out.extend_from_slice(&out_of_line); + out.extend_from_slice(&data); + Some(( + if width > 256 { + "PreviewTIFF" + } else { + "ThumbnailTIFF" + }, + out, + )) +} + /// Parses the thumbnail IFD (IFD1) that follows IFD0 and emits the thumbnail tags. /// /// A JPEG's APP1 EXIF payload is a TIFF structure whose IFD0 carries a @@ -1275,6 +2337,20 @@ pub fn parse_ifd1_thumbnail( for (tag_id, field_type, value_count, raw_bytes) in &entries { match *tag_id { + TAG_SUBFILE_TYPE => { + // Family 1 is IFD1 here, like every sibling in this + // directory: `exiftool -G1` prints `[IFD1] SubfileType`. + metadata.insert( + lookup_tag_name(*tag_id, "IFD1"), + raw_bytes_to_tag_value( + raw_bytes, + *field_type, + *value_count, + *tag_id, + byte_order, + ), + ); + } TAG_COMPRESSION => { compression = raw_bytes_to_tag_value( raw_bytes, @@ -1342,6 +2418,13 @@ pub fn parse_ifd1_thumbnail( } } + if let Some((name, tiff)) = rebuild_thumbnail_tiff(reader, &entries, byte_order) { + // RebuildTIFF names the rebuilt image after the SubfileType tag's + // groups (family 1 = IFD1), calling it PreviewTIFF above 256 pixels + // wide (Exif.pm:6199-6203). + metadata.insert(format!("IFD1:{name}"), TagValue::new_binary(tiff)); + } + // Compression carries the standard PrintConv ("JPEG (old-style)" for a // thumbnail); the exiftool_compat layer applies it to the integer. // @@ -2120,6 +3203,173 @@ mod ifd1_tests { ); } + /// A minimal uncompressed reduced-resolution IFD1 (2x1 grayscale, one + /// strip of "AB") must rebuild into the exact byte stream ExifTool's + /// GenerateTIFF produces: 10-byte header, the fixed 15 entries in + /// ascending tag order, no next IFD, XResolution/YResolution 72/1 + /// out-of-line, then the strip data at offset 210. + #[test] + fn rebuilt_thumbnail_tiff_matches_generate_tiff_byte_layout() { + let thumb = *b"AB"; + // IFD1 sits at 14; ten 12-byte entries + count + next pointer put the + // strip data at 14 + 2 + 120 + 4 = 140. + let strip_offset = 140u32; + let metadata = run( + &[ + (TAG_SUBFILE_TYPE, LONG, 1), + (TAG_IMAGE_WIDTH, LONG, 2), + (TAG_IMAGE_HEIGHT, LONG, 1), + (TAG_BITS_PER_SAMPLE, SHORT, 8), + (TAG_COMPRESSION, SHORT, 1), + (TAG_PHOTOMETRIC_INTERPRETATION, SHORT, 1), + (TAG_STRIP_OFFSETS, LONG, strip_offset), + (TAG_SAMPLES_PER_PIXEL, SHORT, 1), + (TAG_ROWS_PER_STRIP, LONG, 1), + (TAG_STRIP_BYTE_COUNTS, LONG, 2), + ], + &thumb, + 0, + ); + + let mut expected: Vec = Vec::new(); + expected.extend_from_slice(b"II"); + expected.extend_from_slice(&42u16.to_le_bytes()); + expected.extend_from_slice(&8u32.to_le_bytes()); + expected.extend_from_slice(&15u16.to_le_bytes()); + // (tag, type, count, value-field) per GenerateTIFF; SHORT values are + // right-padded to four bytes. + let entries: [(u16, u16, u32, [u8; 4]); 15] = [ + (0x00FE, 4, 1, 0u32.to_le_bytes()), // SubfileType = 0 + (0x0100, 4, 1, 2u32.to_le_bytes()), // ImageWidth + (0x0101, 4, 1, 1u32.to_le_bytes()), // ImageHeight + (0x0102, 3, 1, [8, 0, 0, 0]), // BitsPerSample + (0x0103, 3, 1, [1, 0, 0, 0]), // Compression + (0x0106, 3, 1, [1, 0, 0, 0]), // PhotometricInterpretation + (0x0111, 4, 1, 210u32.to_le_bytes()), // StripOffsets -> data + (0x0112, 3, 1, [1, 0, 0, 0]), // Orientation (default) + (0x0115, 3, 1, [1, 0, 0, 0]), // SamplesPerPixel + (0x0116, 4, 1, 1u32.to_le_bytes()), // RowsPerStrip = height + (0x0117, 4, 1, 2u32.to_le_bytes()), // StripByteCounts + (0x011A, 5, 1, 194u32.to_le_bytes()), // XResolution, out-of-line + (0x011B, 5, 1, 202u32.to_le_bytes()), // YResolution, out-of-line + (0x011C, 3, 1, [1, 0, 0, 0]), // PlanarConfiguration (default) + (0x0128, 3, 1, [2, 0, 0, 0]), // ResolutionUnit = inches + ]; + for (tag, tiff_type, count, value) in entries { + expected.extend_from_slice(&tag.to_le_bytes()); + expected.extend_from_slice(&tiff_type.to_le_bytes()); + expected.extend_from_slice(&count.to_le_bytes()); + expected.extend_from_slice(&value); + } + expected.extend_from_slice(&0u32.to_le_bytes()); // no next IFD + expected.extend_from_slice(&72u32.to_le_bytes()); // XResolution 72/1 + expected.extend_from_slice(&1u32.to_le_bytes()); + expected.extend_from_slice(&72u32.to_le_bytes()); // YResolution 72/1 + expected.extend_from_slice(&1u32.to_le_bytes()); + expected.extend_from_slice(&thumb); + + assert_eq!( + metadata.get("IFD1:ThumbnailTIFF"), + Some(&TagValue::new_binary(expected)) + ); + } + + /// RebuildTIFF proceeds only for SubfileType == 1 with Compression == 1 + /// and strips whose byte count equals rowBytes * RowsPerStrip; anything + /// else is omitted, never approximated. + #[test] + fn rebuilt_thumbnail_tiff_honours_exiftool_gates() { + let thumb = *b"AB"; + let base: [(u16, u16, u32); 10] = [ + (TAG_SUBFILE_TYPE, LONG, 1), + (TAG_IMAGE_WIDTH, LONG, 2), + (TAG_IMAGE_HEIGHT, LONG, 1), + (TAG_BITS_PER_SAMPLE, SHORT, 8), + (TAG_COMPRESSION, SHORT, 1), + (TAG_PHOTOMETRIC_INTERPRETATION, SHORT, 1), + (TAG_STRIP_OFFSETS, LONG, 140), + (TAG_SAMPLES_PER_PIXEL, SHORT, 1), + (TAG_ROWS_PER_STRIP, LONG, 1), + (TAG_STRIP_BYTE_COUNTS, LONG, 2), + ]; + + // SubfileType 0 (full-resolution image): not a thumbnail. + let mut wrong_subfile = base; + wrong_subfile[0].2 = 0; + assert_eq!( + run(&wrong_subfile, &thumb, 0).get("IFD1:ThumbnailTIFF"), + None + ); + + // Compression 6 (JPEG): RebuildTIFF requires uncompressed data. + let mut compressed = base; + compressed[4].2 = 6; + assert_eq!(run(&compressed, &thumb, 0).get("IFD1:ThumbnailTIFF"), None); + + // StripByteCounts != rowBytes * RowsPerStrip: invalid strip, omitted. + let mut bad_strip = base; + bad_strip[9].2 = 3; + assert_eq!(run(&bad_strip, &thumb, 0).get("IFD1:ThumbnailTIFF"), None); + + // A missing required field (no PhotometricInterpretation) also omits. + let missing: Vec<_> = base + .iter() + .copied() + .filter(|entry| entry.0 != TAG_PHOTOMETRIC_INTERPRETATION) + .collect(); + assert_eq!(run(&missing, &thumb, 0).get("IFD1:ThumbnailTIFF"), None); + } + + /// Above 256 pixels wide, RebuildTIFF files the image as PreviewTIFF + /// (Exif.pm:6199-6203), still under IFD1. + #[test] + fn wide_rebuilt_image_is_named_preview_tiff() { + let thumb = [0x55u8; 300]; + let metadata = run( + &[ + (TAG_SUBFILE_TYPE, LONG, 1), + (TAG_IMAGE_WIDTH, LONG, 300), + (TAG_IMAGE_HEIGHT, LONG, 1), + (TAG_BITS_PER_SAMPLE, SHORT, 8), + (TAG_COMPRESSION, SHORT, 1), + (TAG_PHOTOMETRIC_INTERPRETATION, SHORT, 1), + (TAG_STRIP_OFFSETS, LONG, 140), + (TAG_SAMPLES_PER_PIXEL, SHORT, 1), + (TAG_ROWS_PER_STRIP, LONG, 1), + (TAG_STRIP_BYTE_COUNTS, LONG, 300), + ], + &thumb, + 0, + ); + assert_eq!(metadata.get("IFD1:ThumbnailTIFF"), None); + assert!(matches!( + metadata.get("IFD1:PreviewTIFF"), + Some(TagValue::Binary(bytes)) if bytes.len() == 210 + 300 + )); + } + + /// The pinned Leica sample carries the one JPEG-path ThumbnailTIFF in the + /// corpus; the rebuilt stream was verified byte-identical to + /// `exiftool -b -ThumbnailTIFF` (47952 bytes), so pin its shape. + #[test] + fn leica_r9_dmr_thumbnail_tiff_matches_pinned_exiftool() { + if !crate::test_support::pinned_corpus_available() { + return; + } + let path = std::path::Path::new( + "/tmp/oxidex-exiftool-cache/combined-samples/Leica/LeicaR9-DigitalBackDMR.jpg", + ); + let metadata = crate::core::operations::read_metadata(path).expect("Leica R9 DMR parses"); + let Some(TagValue::Binary(tiff)) = metadata.get("IFD1:ThumbnailTIFF") else { + panic!("IFD1:ThumbnailTIFF must be emitted"); + }; + assert_eq!(tiff.len(), 47952); + // Header, entry count, and the strip data offset (216 = 194 + the + // 22 out-of-line bytes: BitsPerSample "8 8 8" plus two rationals). + assert_eq!(&tiff[..10], b"II\x2a\0\x08\0\0\0\x0f\0"); + assert_eq!(&tiff[90..94], &216u32.to_le_bytes()); + } + #[test] fn ifd1_pointer_aimed_at_an_already_visited_directory_is_refused() { // SamsungGT-S5620.jpg aims IFD1 at the GPS IFD; ExifTool warns "IFD1 diff --git a/src/parsers/archive/zip.rs b/src/parsers/archive/zip.rs index 1cb209f39..3e4818d95 100644 --- a/src/parsers/archive/zip.rs +++ b/src/parsers/archive/zip.rs @@ -2,7 +2,9 @@ use crate::core::{FileFormat, FileReader, FormatParser, MetadataMap, TagValue}; use crate::error::{ExifToolError, Result}; -use std::io::Cursor; +use crate::parsers::raw::{RawFormat, parse_raw_metadata}; +use crate::tag_db::lookup_tag_name; +use std::io::{Cursor, Read}; use zip::ZipArchive; const ZIP_SIGNATURE: &[u8] = b"PK"; @@ -14,6 +16,13 @@ const ZIP_LOCAL_FILE_COMPRESSION_FIELD_END: usize = ZIP_LOCAL_FILE_COMPRESSION_O const ZIP_LOCAL_FILE_BIT_FLAG_OFFSET: usize = 6; const ZIP_LOCAL_FILE_CRC_OFFSET: usize = 14; const ZIP_LOCAL_FILE_CRC_FIELD_END: usize = ZIP_LOCAL_FILE_CRC_OFFSET + 4; +/// Ceiling for an embedded EIP raw member's uncompressed size. +/// +/// The declared size comes straight from the attacker-controlled central +/// directory, so it must never drive an allocation. Real Phase One IIQ +/// members top out in the low hundreds of megabytes; 1 GiB leaves ample +/// headroom while keeping a hostile declaration from forcing a huge read. +const EIP_RAW_MEMBER_MAX_SIZE: u64 = 1 << 30; /// Parser for ZIP archive files /// @@ -152,6 +161,60 @@ impl ZipParser { ]))) } + /// Parse the Phase One raw member carried by an EIP archive. + /// + /// EIP is a ZIP container whose `manifest.xml` names an embedded IIQ file. + /// Feeding that member through the existing IIQ/TIFF parser keeps all TIFF + /// byte-order and offset handling in the normal RAW metadata emitter. + fn read_eip_raw_metadata( + archive: &mut ZipArchive>, + ) -> Result> { + let has_manifest = archive + .file_names() + .any(|name| name.eq_ignore_ascii_case("manifest.xml")); + if !has_manifest { + return Ok(None); + } + + let raw_name = archive + .file_names() + .find(|name| { + name.rsplit_once('.') + .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("iiq")) + }) + .map(str::to_owned); + let Some(raw_name) = raw_name else { + return Ok(None); + }; + + let raw_file = archive.by_name(&raw_name).map_err(|error| { + ExifToolError::parse_error(format!("Failed to read EIP raw member: {error}")) + })?; + // The declared uncompressed size is attacker-controlled central + // directory data: reject absurd declarations up front and bound the + // read with `take` instead of pre-allocating from the header. Reading + // one byte past the declared size keeps the original mismatch checks: + // a stream shorter *or* longer than declared still errors below. + let declared_size = raw_file.size(); + if declared_size > EIP_RAW_MEMBER_MAX_SIZE { + return Err(ExifToolError::parse_error("EIP raw member is too large")); + } + let mut raw_data = Vec::new(); + raw_file + .take(declared_size + 1) + .read_to_end(&mut raw_data) + .map_err(|error| { + ExifToolError::parse_error(format!("Failed to extract EIP raw member: {error}")) + })?; + if raw_data.len() as u64 != declared_size { + return Err(ExifToolError::parse_error( + "EIP raw member is shorter than its declared size", + )); + } + + parse_raw_metadata(&raw_data, RawFormat::PhaseOneIIQ).map(Some) + } + /// Converts DOS DateTime to ISO 8601 format string /// /// DOS datetime format: @@ -257,6 +320,44 @@ impl FormatParser for ZipParser { let mut archive = ZipArchive::new(cursor) .map_err(|e| ExifToolError::parse_error(format!("Failed to read ZIP: {}", e)))?; + // RAW parsing preserves physical IFD contexts in its keys. ExifTool's + // EIP reader promotes only these embedded-IIQ fields to EXIF. Resolve + // both names through the tag database rather than assuming prefixes, + // and do not merge the RAW parser's Composite or other IIQ tags. + if let Some(raw_metadata) = Self::read_eip_raw_metadata(&mut archive)? { + const EIP_TAGS: &[(u16, &str)] = &[ + (0x0102, "IFD0"), // BitsPerSample + (0x9102, "ExifIFD"), // CompressedBitsPerPixel + (0x0103, "IFD0"), // Compression + (0x9004, "ExifIFD"), // CreateDate + (0x9003, "ExifIFD"), // DateTimeOriginal + (0xA003, "ExifIFD"), // ExifImageHeight + ]; + + for (tag_id, source_ifd) in EIP_TAGS { + let source_name = lookup_tag_name(*tag_id, source_ifd); + let Some(source_value) = raw_metadata.get(&source_name) else { + continue; + }; + + // Compression's PrintConv is the standard EXIF compression + // table. The RAW parser keeps this IFD0 SHORT as an integer. + let value = if *tag_id == 0x0103 { + source_value + .as_integer() + .and_then(|raw| { + crate::parsers::tiff::tiff_enums::tiff_enum_to_string(*tag_id, raw) + }) + .map(TagValue::new_string) + .unwrap_or_else(|| source_value.clone()) + } else { + source_value.clone() + }; + + metadata.insert(lookup_tag_name(*tag_id, "EXIF"), value); + } + } + // Archive-level metadata let file_count = archive.len(); metadata.insert( diff --git a/src/parsers/detection/signatures.rs b/src/parsers/detection/signatures.rs index 4da57b28e..7cbd65679 100644 --- a/src/parsers/detection/signatures.rs +++ b/src/parsers/detection/signatures.rs @@ -111,6 +111,8 @@ pub static SIMPLE_SIGNATURES: &[Signature] = &[ signature!(b"\x7FELF", 0, FileFormat::ELF), signature!(b"\x89HDF\x0D\x0A\x1A\x0A", 0, FileFormat::HDF5), signature!(b"SIMPLE", 0, FileFormat::FITS), + signature!(b"DICM", 128, FileFormat::DICOM), + signature!(b".FIT", 8, FileFormat::FIT), signature!(b"BEGIN:VCARD", 0, FileFormat::VCF), signature!(b"\x4C\x00\x00\x00", 0, FileFormat::LNK), signature!(b"SQLite format 3\0", 0, FileFormat::SQLite), diff --git a/src/parsers/image/miff.rs b/src/parsers/image/miff.rs index 903b41954..0cbde24ea 100644 --- a/src/parsers/image/miff.rs +++ b/src/parsers/image/miff.rs @@ -15,8 +15,9 @@ //! //! `profile-*` entries name an embedded profile (ICC/IPTC/EXIF/XMP) whose //! value is the profile's byte length; the profile bytes immediately follow -//! the text header in the file. Profile sub-parsing is not implemented here. +//! the text header in the file. +use super::embedded::parse_embedded_exif; use crate::core::{FileReader, MetadataMap, TagValue}; const MIFF_HEADER: &[u8] = b"id=ImageMagick"; @@ -89,11 +90,10 @@ pub fn parse_miff_metadata(reader: &dyn FileReader) -> std::result::Result std::result::Result = Vec::new(); for entry in entries { match mode { @@ -172,12 +173,9 @@ pub fn parse_miff_metadata(reader: &dyn FileReader) -> std::result::Result() { + profiles.push((tag.clone(), length)); + } } else if let Some(name) = known_tag_name(&tag) { metadata.insert(name.to_string(), TagValue::String(val.clone())); } else { @@ -187,6 +185,74 @@ pub fn parse_miff_metadata(reader: &dyn FileReader) -> std::result::Result 12, so offset-bearing + // tags (e.g. ThumbnailOffset) need that base applied to match the oracle. + const MIFF_EXIF_TAGS: [&str; 6] = [ + "ApertureValue", + "Artist", + "BrightnessValue", + "ColorSpace", + "ComponentsConfiguration", + "CompressedBitsPerPixel", + ]; + + if let Some(header_end) = terminator_pos { + let Some(profile_start) = header_end.checked_add(NEW_TERMINATOR.len()) else { + return Ok(metadata); + }; + let mut profile_offset = match u64::try_from(profile_start) { + Ok(offset) => offset, + Err(_) => return Ok(metadata), + }; + + for (profile_name, profile_length) in profiles { + let Ok(profile_size) = usize::try_from(profile_length) else { + break; + }; + let Some(next_offset) = profile_offset.checked_add(profile_length) else { + break; + }; + if next_offset > size { + break; + } + + let profile = match reader.read(profile_offset, profile_size) { + Ok(profile) => profile, + Err(_) => break, + }; + + // ExifTool dispatches case-sensitively on the text after + // "profile-": `$type eq 'APP1' or $type eq 'exif' or $type eq + // 'xmp'` selects the Exif-header check. Only the observed + // 'profile-APP1' spelling is wired here; 'profile-exif' and + // 'profile-xmp' (never seen per MIFF.pm) remain unhandled, and a + // lowercase 'profile-app1' is skipped exactly as ExifTool skips it. + if profile_name == "profile-APP1" + && let Some(tiff_data) = profile.strip_prefix(b"Exif\0\0") + { + let mut embedded = MetadataMap::new(); + if parse_embedded_exif(tiff_data, &mut embedded) { + for (key, value) in embedded { + let base_name = key.split_once(':').map_or(key.as_str(), |(_, name)| name); + if MIFF_EXIF_TAGS.contains(&base_name) { + metadata.insert(key, value); + } + } + } + } + + profile_offset = next_offset; + } + } + Ok(metadata) } diff --git a/src/parsers/raw/metadata.rs b/src/parsers/raw/metadata.rs index 6282c56b0..59535f24b 100644 --- a/src/parsers/raw/metadata.rs +++ b/src/parsers/raw/metadata.rs @@ -6306,6 +6306,204 @@ fn parse_minolta_mrw(data: &[u8], format: RawFormat) -> Result { Ok(metadata) } +fn read_ciff_u16(data: &[u8], offset: usize) -> Option { + let end = offset.checked_add(2)?; + let bytes: [u8; 2] = data.get(offset..end)?.try_into().ok()?; + Some(u16::from_le_bytes(bytes)) +} + +fn read_ciff_u32(data: &[u8], offset: usize) -> Option { + let end = offset.checked_add(4)?; + let bytes: [u8; 4] = data.get(offset..end)?.try_into().ok()?; + Some(u32::from_le_bytes(bytes)) +} + +fn canon_crw_tag_key(table_name: &str, field_name: &str) -> Option { + let requested_table = find_table("Canon", table_name); + let group = requested_table + .or_else(|| find_table("Canon", "ShotInfo"))? + .group0; + let registered_name = requested_table + .and_then(|table| table.fields.iter().find(|field| field.name == field_name)) + .map(|field| field.name) + .unwrap_or(field_name); + Some(format!("{group}:{registered_name}")) +} + +fn insert_canon_crw_tag( + metadata: &mut MetadataMap, + table_name: &str, + field_name: &str, + value: TagValue, +) { + if let Some(key) = canon_crw_tag_key(table_name, field_name) { + metadata.insert(key, value); + } +} + +fn canon_ev(raw: i16) -> f64 { + let sign = if raw < 0 { -1.0 } else { 1.0 }; + let value = i32::from(raw).unsigned_abs(); + let fraction = value & 0x1f; + let whole = value - fraction; + let fraction = match fraction { + 0x0c => 32.0 / 3.0, + 0x14 => 64.0 / 3.0, + other => f64::from(other), + }; + sign * (f64::from(whole) + fraction) / 32.0 +} + +fn parse_ciff_record(tag: u16, record: &[u8], metadata: &mut MetadataMap) { + match tag { + // CanonRaw.pm tag 0x102a -> Canon::ShotInfo. The generated table + // identifies AEBBracketValue as int16 index 17. + 0x102A => { + if let Some(raw) = read_ciff_u16(record, 17 * 2).map(|value| value as i16) { + insert_canon_crw_tag( + metadata, + "ShotInfo", + "AEBBracketValue", + TagValue::new_string(print_fraction(canon_ev(raw))), + ); + } + } + // CanonRaw.pm tag 0x1038 -> Canon::AFInfo. This serial record has no + // leading length word; NumAFPoints at index zero controls both arrays. + // The table's default FORMAT is int16u (Canon.pm 13.59), so scalar + // entries decode unsigned; only the per-entry int16s[$val{0}] arrays + // (AFAreaXPositions/AFAreaYPositions) are signed. + 0x1038 => { + let values = record + .chunks_exact(2) + .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]])) + .collect::>(); + + for (index, name) in [ + (5usize, "AFImageHeight"), + (6, "AFAreaWidth"), + (7, "AFAreaHeight"), + ] { + if let Some(&value) = values.get(index) { + insert_canon_crw_tag( + metadata, + "AFInfo", + name, + TagValue::new_integer(i64::from(value)), + ); + } + } + + let Some(&point_count) = values.first() else { + return; + }; + if point_count == 0 { + return; + } + let point_count = usize::from(point_count); + let x_start = 8usize; + let Some(y_start) = x_start.checked_add(point_count) else { + return; + }; + let Some(end) = y_start.checked_add(point_count) else { + return; + }; + + for (name, range) in [ + ("AFAreaXPositions", x_start..y_start), + ("AFAreaYPositions", y_start..end), + ] { + if let Some(positions) = values.get(range) { + let display = positions + .iter() + .map(|&value| (value as i16).to_string()) + .collect::>() + .join(" "); + insert_canon_crw_tag(metadata, "AFInfo", name, TagValue::new_string(display)); + } + } + } + _ => {} + } +} + +fn parse_ciff_directory( + data: &[u8], + container_start: usize, + container_end: usize, + directory_offset: usize, + metadata: &mut MetadataMap, + depth: usize, +) { + if depth > 16 { + return; + } + let Some(entry_count) = read_ciff_u16(data, directory_offset).map(usize::from) else { + return; + }; + if entry_count > 256 { + return; + } + let Some(directory_end) = entry_count + .checked_mul(10) + .and_then(|size| directory_offset.checked_add(2 + size + 4)) + else { + return; + }; + if directory_end > container_end || directory_end > data.len() { + return; + } + + for index in 0..entry_count { + let Some(entry_offset) = index + .checked_mul(10) + .and_then(|offset| directory_offset.checked_add(2 + offset)) + else { + continue; + }; + let Some(tag) = read_ciff_u16(data, entry_offset) else { + continue; + }; + let Some(size) = read_ciff_u32(data, entry_offset + 2).map(|value| value as usize) else { + continue; + }; + let Some(relative) = read_ciff_u32(data, entry_offset + 6).map(|value| value as usize) + else { + continue; + }; + let Some(value_start) = container_start.checked_add(relative) else { + continue; + }; + let Some(value_end) = value_start.checked_add(size) else { + continue; + }; + let Some(value) = data.get(value_start..value_end) else { + continue; + }; + + parse_ciff_record(tag, value, metadata); + + if tag & 0x3800 == 0x3000 && value.len() >= 4 { + let Some(relative_directory) = + read_ciff_u32(value, value.len() - 4).map(|value| value as usize) + else { + continue; + }; + let Some(nested_directory) = value_start.checked_add(relative_directory) else { + continue; + }; + parse_ciff_directory( + data, + value_start, + value_end, + nested_directory, + metadata, + depth + 1, + ); + } + } +} + /// Parse Canon CRW format /// /// CRW is Canon's older proprietary raw format used before CR2. @@ -6325,7 +6523,7 @@ fn parse_minolta_mrw(data: &[u8], format: RawFormat) -> Result { /// /// - Implement CRW format parser /// - Extract Canon-specific metadata from CRW structure -fn parse_canon_crw(_data: &[u8], format: RawFormat) -> Result { +fn parse_canon_crw(data: &[u8], format: RawFormat) -> Result { let mut metadata = MetadataMap::new(); metadata.insert( "File:FileType".to_string(), @@ -6338,8 +6536,37 @@ fn parse_canon_crw(_data: &[u8], format: RawFormat) -> Result { ); } - // TODO: Implement CRW specific parsing - // CRW is Canon's older proprietary format + // CIFF stores its heap start in the header. Canon CRW uses little-endian + // CIFF, and the final heap word is the root-directory offset relative to + // that start. + if data.get(..2) != Some(b"II") || data.get(6..14) != Some(b"HEAPCCDR") { + return Ok(metadata); + } + let Some(heap_start) = read_ciff_u32(data, 2).map(|value| value as usize) else { + return Ok(metadata); + }; + if heap_start >= data.len() || data.len().saturating_sub(heap_start) < 4 { + return Ok(metadata); + } + let Some(root_relative) = read_ciff_u32(data, data.len() - 4).map(|value| value as usize) + else { + return Ok(metadata); + }; + let Some(root_directory) = heap_start.checked_add(root_relative) else { + return Ok(metadata); + }; + if root_directory >= data.len() { + return Ok(metadata); + } + + parse_ciff_directory( + data, + heap_start, + data.len(), + root_directory, + &mut metadata, + 0, + ); Ok(metadata) } diff --git a/src/parsers/specialized/fit.rs b/src/parsers/specialized/fit.rs new file mode 100644 index 000000000..79a4de2cd --- /dev/null +++ b/src/parsers/specialized/fit.rs @@ -0,0 +1,293 @@ +//! Garmin FIT activity-file parser. +//! +//! Modeled on ExifTool 13.59 Garmin.pm ProcessFIT. Divergences are always +//! omissions, never approximations: fields whose exact ExifTool rendering +//! cannot be reproduced (string/byte/float base types, oversized values fed +//! through Perl numeric stringification) are skipped, not guessed. + +use crate::core::{FileFormat, FileReader, FormatParser, MetadataMap, TagValue}; +use crate::error::{ExifToolError, Result}; + +const SESSION_MESSAGE: u16 = 18; + +#[derive(Clone, Copy)] +struct Field { + number: u8, + size: usize, + base_type: u8, +} + +struct Definition { + global_number: u16, + big_endian: bool, + fields: Vec, + developer_size: usize, +} + +pub struct FITParser; + +impl FITParser { + pub fn verify_signature(reader: &dyn FileReader) -> Result { + if reader.size() < 12 { + return Ok(false); + } + Ok(reader.read(8, 4)? == b".FIT") + } + + /// Walk the record stream. A mid-stream malformation (missing local + /// definition, truncated record, ...) ends the walk but keeps everything + /// already extracted, matching ExifTool's warn-and-return-1 tolerance. + fn parse_records(data: &[u8], metadata: &mut MetadataMap) { + let mut definitions: [Option; 16] = std::array::from_fn(|_| None); + let mut offset = 0usize; + // ExifTool without ExtractEmbedded processes only the FIRST data + // message of each global message number (the %done gate). + let mut session_done = false; + + while offset < data.len() { + let header = data[offset]; + offset += 1; + let compressed = header & 0x80 != 0; + let local = if compressed { + usize::from((header >> 5) & 0x03) + } else { + usize::from(header & 0x0f) + }; + + if !compressed && header & 0x40 != 0 { + // Definition message: reserved, architecture, global number, + // field count, then 3 bytes per field. + let Some(fixed) = data.get(offset..offset + 5) else { + break; + }; + // ExifTool: SetByteOrder(Get8u(..) ? 'MM' : 'II') -- any + // non-zero architecture byte selects big-endian. + let big_endian = fixed[1] != 0; + let global_number = if big_endian { + u16::from_be_bytes([fixed[2], fixed[3]]) + } else { + u16::from_le_bytes([fixed[2], fixed[3]]) + }; + let count = usize::from(fixed[4]); + offset += 5; + let Some(bytes) = data.get(offset..offset + count * 3) else { + break; + }; + // Fields with base types ExifTool does not know still count + // toward the record size (only extraction skips them), so + // keep every declared field here. + let fields = bytes + .chunks_exact(3) + .map(|field| Field { + number: field[0], + size: usize::from(field[1]), + base_type: field[2], + }) + .collect(); + offset += count * 3; + + let mut developer_size = 0usize; + if header & 0x20 != 0 { + let Some(&dev_count) = data.get(offset) else { + break; + }; + offset += 1; + let length = usize::from(dev_count) * 3; + let Some(bytes) = data.get(offset..offset + length) else { + break; + }; + developer_size = bytes + .chunks_exact(3) + .map(|field| usize::from(field[1])) + .sum(); + offset += length; + } + definitions[local] = Some(Definition { + global_number, + big_endian, + fields, + developer_size, + }); + continue; + } + + // Data message (normal or compressed-timestamp header). ExifTool + // reads the full defined size in both cases -- field 253 is NOT + // elided from compressed-header records. + let Some(definition) = definitions[local].as_ref() else { + break; // "Missing definition for local message" + }; + let record_size: usize = definition + .fields + .iter() + .map(|field| field.size) + .sum::() + + definition.developer_size; + let Some(record) = data.get(offset..offset + record_size) else { + break; // "Truncated data message" + }; + if definition.global_number == SESSION_MESSAGE && !session_done { + session_done = true; + Self::extract_session(definition, record, metadata); + } + offset += record_size; + } + } + + fn extract_session(definition: &Definition, record: &[u8], metadata: &mut MetadataMap) { + let mut offset = 0usize; + for field in &definition.fields { + let Some(value) = record.get(offset..offset + field.size) else { + return; + }; + offset += field.size; + if !matches!(field.number, 16 | 18 | 92 | 116..=119 | 122) { + continue; + } + let Some(text) = Self::read_value(value, field.base_type, definition.big_endian) else { + continue; // invalid sentinel, unknown type, or bad count + }; + // Garmin.pm %Image::ExifTool::Garmin::Session (13.59): + // 16 => AvgHeartRate PrintConv '"$val bpm"' + // 18 => AvgCadence PrintConv '"$val rpm"' + // 92 => AvgFractionalCadence ValueConv '$val / 128', PrintConv '"$val rpm"' + // 116 => AvgLeftPowerPhase 117 => AvgLeftPowerPhasePeak + // 118 => AvgRightPowerPhase 119 => AvgRightPowerPhasePeak + // 122 => AvgCadencePosition + match field.number { + 16 => { + metadata.insert( + "Garmin:AvgHeartRate", + TagValue::String(format!("{text} bpm")), + ); + } + 18 => { + metadata.insert("Garmin:AvgCadence", TagValue::String(format!("{text} rpm"))); + } + 92 => { + // ValueConv '$val / 128': Perl numifies the value string, + // i.e. uses its leading number even for arrays. Restrict + // to magnitudes below 2^32 so the quotient needs at most + // 15 significant digits and Rust's float formatting is + // guaranteed to match Perl's %.15g; larger values are + // omitted rather than approximated. + let first = text.split(' ').next().unwrap_or(""); + if let Ok(raw) = first.parse::() { + if raw.unsigned_abs() < 1 << 32 { + metadata.insert( + "Garmin:AvgFractionalCadence", + TagValue::String(format!("{} rpm", raw as f64 / 128.0)), + ); + } + } + } + 116 => { + metadata.insert("Garmin:AvgLeftPowerPhase", TagValue::String(text)); + } + 117 => { + metadata.insert("Garmin:AvgLeftPowerPhasePeak", TagValue::String(text)); + } + 118 => { + metadata.insert("Garmin:AvgRightPowerPhase", TagValue::String(text)); + } + 119 => { + metadata.insert("Garmin:AvgRightPowerPhasePeak", TagValue::String(text)); + } + 122 => { + metadata.insert("Garmin:AvgCadencePosition", TagValue::String(text)); + } + _ => {} + } + } + } + + /// Decode one field the way ExifTool's ReadValue + invalid-sentinel check + /// does (Garmin.pm %baseType), returning the space-joined value string. + /// + /// Returns None when ExifTool would skip the field (base type not in + /// %baseType, non-integral count, single value equal to the type's + /// invalid sentinel -- multi-element arrays of sentinels are kept, per + /// the string comparison `lc $val eq $baseType{$type}[2]`) and for the + /// string/byte/float base types, whose Perl rendering we cannot + /// reproduce exactly and therefore omit rather than approximate. + fn read_value(value: &[u8], base_type: u8, big_endian: bool) -> Option { + let (width, signed, sentinel): (usize, bool, &str) = match base_type { + 0x00 | 0x02 => (1, false, "255"), // enum, uint8 + 0x01 => (1, true, "127"), // sint8 + 0x83 => (2, true, "32767"), // sint16 + 0x84 => (2, false, "65535"), // uint16 + 0x85 => (4, true, "2147483647"), // sint32 + 0x86 => (4, false, "4294967295"), // uint32 + 0x0a => (1, false, "0"), // uint8z + 0x8b => (2, false, "0"), // uint16z + 0x8c => (4, false, "0"), // uint32z + 0x8e => (8, true, "9223372036854775807"), // sint64 + 0x8f => (8, false, "18446744073709551615"), // uint64 + 0x90 => (8, false, "0"), // uint64z + // 0x07 string, 0x0d byte, 0x88 float32, 0x89 float64: omitted + // (cannot guarantee ExifTool's exact formatting); anything else + // is not in %baseType and ExifTool never extracts it. + _ => return None, + }; + if value.is_empty() || value.len() % width != 0 { + return None; // ExifTool: "Bad count" warning, field skipped + } + let mut parts = Vec::with_capacity(value.len() / width); + for chunk in value.chunks_exact(width) { + let mut raw = 0u64; + if big_endian { + for &byte in chunk { + raw = raw << 8 | u64::from(byte); + } + } else { + for &byte in chunk.iter().rev() { + raw = raw << 8 | u64::from(byte); + } + } + if signed { + let shift = 64 - width * 8; + parts.push((((raw << shift) as i64) >> shift).to_string()); + } else { + parts.push(raw.to_string()); + } + } + let text = parts.join(" "); + if text == sentinel { + return None; // invalid value, suppressed by ExifTool + } + Some(text) + } +} + +impl FormatParser for FITParser { + fn parse(&self, reader: &dyn FileReader) -> Result { + if !Self::verify_signature(reader)? { + return Err(ExifToolError::parse_error("invalid FIT signature")); + } + // ExifTool reads 12 header bytes (so data never starts before offset + // 12), takes the data length from bytes 4..8, and stops at + // header_size + data_size without pre-validating it against the file + // size -- a truncated file still yields the tags read so far. + let header = reader.read(0, 12)?; + let header_size = usize::from(header[0]); + let data_size = u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize; + let start = header_size.max(12); + let limit = header_size + .saturating_add(data_size) + .min(reader.size() as usize); + let mut metadata = MetadataMap::new(); + if limit > start { + let data = reader.read(start as u64, limit - start)?; + Self::parse_records(data, &mut metadata); + } + Ok(metadata) + } + + fn supports_format(&self, format: FileFormat) -> bool { + format == FileFormat::FIT + } +} + +pub fn parse_fit_metadata(reader: &dyn FileReader) -> std::result::Result { + FITParser.parse(reader).map_err(|error| error.to_string()) +} diff --git a/src/parsers/specialized/fits.rs b/src/parsers/specialized/fits.rs index 70ec1ff9e..e93a9c0de 100644 --- a/src/parsers/specialized/fits.rs +++ b/src/parsers/specialized/fits.rs @@ -4,6 +4,7 @@ use crate::core::{FileFormat, FileReader, FormatParser, MetadataMap, TagValue}; use crate::error::{ExifToolError, Result}; +use crate::io::{ByteOrder, EndianReader}; mod tables; @@ -262,6 +263,710 @@ pub fn parse_fits_metadata(reader: &dyn FileReader) -> std::result::Result { + group: u16, + element: u16, + /// The VR from the element header in explicit-VR syntax; `None` for + /// implicit-VR syntax and for the FFFE item/delimiter tags, which carry + /// no VR field in any syntax. + vr: Option<[u8; 2]>, + value: &'a [u8], + next_offset: usize, +} + +/// ExifTool 13.59's `%vr32`: the VRs framed with a 32-bit length (12-byte +/// header) in explicit VR syntax. The 2013+ DICOM standard also frames +/// OD/OL/OV/UC/UR that way, but the pinned oracle does not, and the oracle's +/// framing is authoritative here: diverging would desynchronize every +/// element that follows one of those VRs. +fn dicom_long_vr(vr: [u8; 2]) -> bool { + matches!(&vr, b"OB" | b"OW" | b"OF" | b"SQ" | b"UT" | b"UN") +} + +/// ExifTool's `%implicitVR`: item/delimiter tags that never carry a VR +/// field, even in explicit-VR syntax. +fn dicom_implicit_tag(group: u16, element: u16) -> bool { + group == 0xFFFE && matches!(element, 0xE000 | 0xE00D | 0xE0DD) +} + +fn parse_dicom_element<'a>( + data: &'a [u8], + offset: usize, + encoding: DicomEncoding, +) -> Result> { + let reader = EndianReader::new(data, encoding.order); + let group = reader + .u16_at(offset) + .ok_or_else(|| ExifToolError::parse_error_at("truncated DICOM tag group", offset))?; + let element = reader + .u16_at(offset + 2) + .ok_or_else(|| ExifToolError::parse_error_at("truncated DICOM tag element", offset))?; + + let (header_len, value_len, vr) = if !encoding.explicit_vr || dicom_implicit_tag(group, element) + { + ( + 8usize, + reader.u32_at(offset + 4).ok_or_else(|| { + ExifToolError::parse_error_at("truncated DICOM value length", offset) + })?, + None, + ) + } else { + let vr_bytes = data.get(offset + 4..offset + 6).ok_or_else(|| { + ExifToolError::parse_error_at("truncated DICOM value representation", offset) + })?; + let vr = [vr_bytes[0], vr_bytes[1]]; + // ExifTool stops the walk on a VR that is not two uppercase letters + // (`last unless $vr =~ /^[A-Z]{2}$/`). + if !vr.iter().all(u8::is_ascii_uppercase) { + return Err(ExifToolError::parse_error_at( + "invalid DICOM value representation", + offset, + )); + } + if dicom_long_vr(vr) { + let len = reader.u32_at(offset + 8).ok_or_else(|| { + ExifToolError::parse_error_at("truncated DICOM value length", offset) + })?; + // ExifTool forces the length to 0 for SQ so the walk simply + // continues into the sequence contents ("just recurse into + // sequences"), extracting whatever elements it meets there. + let len = if &vr == b"SQ" { 0 } else { len }; + (12usize, len, Some(vr)) + } else { + ( + 8usize, + u32::from(reader.u16_at(offset + 6).ok_or_else(|| { + ExifToolError::parse_error_at("truncated DICOM value length", offset) + })?), + Some(vr), + ) + } + }; + + // Undefined length: ExifTool reads no value (`$len = 0`) and keeps + // walking -- the enclosed items are themselves element-framed, so the + // walk stays in sync and the file is never failed outright. + let value_len = if value_len == u32::MAX { 0 } else { value_len }; + let value_len = usize::try_from(value_len) + .map_err(|_| ExifToolError::parse_error_at("DICOM value is too large", offset))?; + let value_start = offset + .checked_add(header_len) + .ok_or_else(|| ExifToolError::parse_error_at("DICOM offset overflow", offset))?; + let next_offset = value_start + .checked_add(value_len) + .ok_or_else(|| ExifToolError::parse_error_at("DICOM value length overflow", offset))?; + let value = data + .get(value_start..next_offset) + .ok_or_else(|| ExifToolError::parse_error_at("DICOM value extends beyond file", offset))?; + + Ok(DicomElement { + group, + element, + vr, + value, + next_offset, + }) +} + +fn trim_trailing_spaces(bytes: &[u8]) -> &[u8] { + let end = bytes + .iter() + .rposition(|&byte| byte != b' ') + .map_or(0, |pos| pos + 1); + &bytes[..end] +} + +fn leading_space_count(bytes: &[u8]) -> usize { + bytes.iter().take_while(|&&byte| byte == b' ').count() +} + +/// DA conversion: `s/^ *(\d{4})(\d{2})(\d{2})/$1:$2:$3/` (pinned DICOM.pm). +/// A prefix match, so multi-value dates ("20010316\\20010317") convert their +/// first value and space-padded dates convert too; anything without eight +/// leading digits passes through untouched. +fn dicom_date_bytes(bytes: &[u8]) -> Vec { + let rest = &bytes[leading_space_count(bytes)..]; + if rest.len() >= 8 && rest[..8].iter().all(u8::is_ascii_digit) { + let mut out = Vec::with_capacity(rest.len() + 2); + out.extend_from_slice(&rest[..4]); + out.push(b':'); + out.extend_from_slice(&rest[4..6]); + out.push(b':'); + out.extend_from_slice(&rest[6..]); + out + } else { + bytes.to_vec() + } +} + +/// TM conversion: `s/^ *(\d{2})(\d{2})(\d{2}[^ ]*)/$1:$2:$3/` (pinned +/// DICOM.pm). Six leading digits are required, so legal partial times like +/// "1434" are reported verbatim, exactly as ExifTool does. +fn dicom_time_bytes(bytes: &[u8]) -> Vec { + let rest = &bytes[leading_space_count(bytes)..]; + if rest.len() >= 6 && rest[..6].iter().all(u8::is_ascii_digit) { + let mut out = Vec::with_capacity(rest.len() + 2); + out.extend_from_slice(&rest[..2]); + out.push(b':'); + out.extend_from_slice(&rest[2..4]); + out.push(b':'); + out.extend_from_slice(&rest[4..]); + out + } else { + bytes.to_vec() + } +} + +/// DT conversion: +/// `s/^ *(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2}[^ ]*)/$1:$2:$3 $4:$5:$6/`. +fn dicom_datetime_bytes(bytes: &[u8]) -> Vec { + let rest = &bytes[leading_space_count(bytes)..]; + if rest.len() >= 14 && rest[..14].iter().all(u8::is_ascii_digit) { + let mut out = Vec::with_capacity(rest.len() + 5); + out.extend_from_slice(&rest[..4]); + out.push(b':'); + out.extend_from_slice(&rest[4..6]); + out.push(b':'); + out.extend_from_slice(&rest[6..8]); + out.push(b' '); + out.extend_from_slice(&rest[8..10]); + out.push(b':'); + out.extend_from_slice(&rest[10..12]); + out.push(b':'); + out.extend_from_slice(&rest[12..]); + out + } else { + bytes.to_vec() + } +} + +/// Converts a string element exactly as ProcessDICOM does for its effective +/// VR. Returns `None` when the resulting bytes are not valid UTF-8: the +/// oracle emits raw bytes (Perl strings are byte strings), which a Rust +/// `String` cannot hold losslessly, so the tag is omitted rather than +/// approximated with U+FFFD replacement characters. +fn dicom_string_value(vr: [u8; 2], value: &[u8], order: ByteOrder) -> Option { + // `$buff =~ s/ $// unless $format or length($buff) & 0x01;` -- exactly + // one trailing space (the even-length pad) is removed before any VR + // rule. Format VRs never reach this function. + let mut bytes = value; + if bytes.len() % 2 == 0 { + if let Some(stripped) = bytes.strip_suffix(b" ") { + bytes = stripped; + } + } + + let converted: Vec = match &vr { + b"DA" => dicom_date_bytes(bytes), + b"TM" => dicom_time_bytes(bytes), + b"DT" => dicom_datetime_bytes(bytes), + // `$val =~ s/\0.*//s;` -- only UI truncates at a null byte. + b"UI" => bytes + .iter() + .position(|&byte| byte == 0) + .map_or_else(|| bytes.to_vec(), |pos| bytes[..pos].to_vec()), + // A 4-byte AT value renders as a hex attribute-tag ID. + b"AT" if bytes.len() == 4 => { + let reader = EndianReader::new(bytes, order); + let group = reader.u16_at(0)?; + let element = reader.u16_at(2)?; + format!("{group:04X},{element:04X}").into_bytes() + } + // `s/ +$//; s/^ +//` -- leading/trailing spaces not significant. + b"AE" | b"CS" | b"DS" | b"IS" | b"LO" | b"PN" | b"SH" => { + let trimmed = trim_trailing_spaces(bytes); + trimmed[leading_space_count(trimmed)..].to_vec() + } + // `s/ +$//` -- trailing spaces not significant. + b"LT" | b"ST" | b"UT" => trim_trailing_spaces(bytes).to_vec(), + // Every other string VR keeps its bytes (after the pad trim above); + // in particular trailing nulls are NOT stripped outside UI. + _ => bytes.to_vec(), + }; + String::from_utf8(converted).ok() +} + +/// US/OW values follow ExifTool's `ReadValue(..., 'int16u', undef, $len)`: +/// the count is floor(len / 2), so a stray odd byte is ignored, not an +/// error. +fn dicom_int16u(value: &[u8], order: ByteOrder) -> String { + let reader = EndianReader::new(value, order); + let mut values = Vec::with_capacity(value.len() / 2); + for index in 0..value.len() / 2 { + if let Some(number) = reader.u16_at(index * 2) { + values.push(number.to_string()); + } + } + values.join(" ") +} + +fn dicom_tag_name(group: u16, element: u16) -> Option { + let name = match (group, element) { + (0x0008, 0x0050) => "AccessionNumber", + (0x0008, 0x0022) => "AcquisitionDate", + (0x0008, 0x0032) => "AcquisitionTime", + (0x0010, 0x21B0) => "AdditionalPatientHistory", + (0x0018, 0x1310) => "AcquisitionMatrix", + (0x0020, 0x0012) => "AcquisitionNumber", + _ => return None, + }; + + let table = oxidex_tags::specialty::get_tag_table("DICOM::Main")?; + let tag = table.tags.iter().find(|tag| tag.name == name)?; + let group = table.name.split("::").next()?; + Some(format!("{group}:{}", tag.name)) +} + +/// The pinned table VR for each wired tag (`%Image::ExifTool::DICOM::Main`), +/// used when the transfer syntax is implicit VR, mirroring +/// `$vr = $$tagInfo{VR} ... if $tagInfo and not $vr`. +fn dicom_table_vr(group: u16, element: u16) -> Option<[u8; 2]> { + let vr: &[u8; 2] = match (group, element) { + (0x0008, 0x0050) => b"SH", + (0x0008, 0x0022) => b"DA", + (0x0008, 0x0032) => b"TM", + (0x0010, 0x21B0) => b"LT", + (0x0018, 0x1310) => b"US", + (0x0020, 0x0012) => b"IS", + _ => return None, + }; + Some(*vr) +} + +/// Converts a wired element's value; `None` omits the tag (per repo rule: +/// omit when the oracle's exact output cannot be reproduced). +fn dicom_value(element: &DicomElement<'_>, encoding: DicomEncoding) -> Option { + // ExifTool renders any element longer than 1024 bytes as a binary-data + // placeholder; rather than approximate that rendering, omit. + if element.value.len() > 1024 { + return None; + } + // In explicit VR the header's VR is authoritative even when it disagrees + // with the table; the table VR applies only to implicit VR syntax. + let vr = element + .vr + .or_else(|| dicom_table_vr(element.group, element.element))?; + let value = match &vr { + // `%dicomFormat` maps US and OW to int16u. + b"US" | b"OW" => dicom_int16u(element.value, encoding.order), + // The remaining %dicomFormat VRs (FD/FL/OB/OF/SL/SS/UL) cannot occur + // for the wired tags in a conformant file; rather than approximate + // ExifTool's numeric formatting for them, omit. + b"FD" | b"FL" | b"OB" | b"OF" | b"SL" | b"SS" | b"UL" => return None, + _ => dicom_string_value(vr, element.value, encoding.order)?, + }; + Some(TagValue::String(value)) +} + +/// Dispatches TransferSyntaxUID exactly as ExifTool's +/// `/^1\.2\.840\.10008\.1\.2(\.\d+)?(\.\d+)?/` prefix match does. +fn dicom_transfer_syntax(value: &[u8]) -> DicomSyntax { + // The stored $transferSyntax already went through the UI string rules: + // one even-length pad space removed, truncated at the first null. + let mut bytes = value; + if bytes.len() % 2 == 0 { + if let Some(stripped) = bytes.strip_suffix(b" ") { + bytes = stripped; + } + } + let bytes = bytes + .iter() + .position(|&byte| byte == 0) + .map_or(bytes, |pos| &bytes[..pos]); + + let Some(rest) = bytes.strip_prefix(b"1.2.840.10008.1.2".as_slice()) else { + // ExifTool: "Unrecognized transfer syntax" warning, then stop. + return DicomSyntax::Unsupported; + }; + let (first, rest) = dicom_take_dot_digits(rest); + let (second, _) = dicom_take_dot_digits(rest); + match (first, second) { + // 1.2.840.10008.1.2 = implicit VR little endian + (None, _) => DicomSyntax::Encoding(DicomEncoding::IMPLICIT_LE), + // 1.2.840.10008.1.2.2 = explicit VR big endian + (Some(first), _) if first == b".2".as_slice() => { + DicomSyntax::Encoding(DicomEncoding::EXPLICIT_BE) + } + // 1.2.840.10008.1.2.1.99 = deflated + (Some(first), Some(second)) if first == b".1".as_slice() && second == b".99".as_slice() => { + DicomSyntax::Unsupported + } + // 1.2.840.10008.1.2.x = explicit VR little endian + (Some(_), _) => DicomSyntax::Encoding(DicomEncoding::EXPLICIT_LE), + } +} + +/// One `(\.\d+)?` capture: a dot followed by at least one ASCII digit. +fn dicom_take_dot_digits(bytes: &[u8]) -> (Option<&[u8]>, &[u8]) { + if bytes.first() != Some(&b'.') { + return (None, bytes); + } + let digits = bytes[1..] + .iter() + .take_while(|byte| byte.is_ascii_digit()) + .count(); + if digits == 0 { + return (None, bytes); + } + (Some(&bytes[..1 + digits]), &bytes[1 + digits..]) +} + +/// Parses DICOM Part 10 metadata using this existing specialty parser module. +pub fn parse_dicom_metadata(reader: &dyn FileReader) -> Result { + let size = usize::try_from(reader.size()) + .map_err(|_| ExifToolError::parse_error("DICOM file is too large"))?; + let data = reader.read(0, size)?; + + if data.get(DICOM_MAGIC_OFFSET..DICOM_DATA_OFFSET) != Some(b"DICM") { + return Err(ExifToolError::parse_error("invalid DICOM signature")); + } + + let mut metadata = MetadataMap::new(); + let mut offset = DICOM_DATA_OFFSET; + let mut data_syntax = DicomSyntax::Encoding(DicomEncoding::EXPLICIT_LE); + let mut file_meta = true; + + // Mid-file failures never fail the file: each `break` below mirrors a + // `last` in ProcessDICOM, which warns "Error reading DICOM file + // (corrupted?)" and still reports everything already extracted. + while offset + 8 <= data.len() { + if file_meta { + // The file-meta group is always explicit little-endian; the data + // syntax takes over at the first element outside group 0x0002. + let little = EndianReader::little_endian(data); + let Some(group) = little.u16_at(offset) else { + break; + }; + if group != 0x0002 { + file_meta = false; + } + } + + let encoding = if file_meta { + DicomEncoding::EXPLICIT_LE + } else { + match data_syntax { + DicomSyntax::Encoding(encoding) => encoding, + // Deflated or unrecognized transfer syntax: stop the walk, + // keep what the file-meta group gave us. + DicomSyntax::Unsupported => break, + } + }; + let Ok(element) = parse_dicom_element(data, offset, encoding) else { + // Truncated or malformed element: ExifTool warns "(corrupted?)" + // and reports everything already read. + break; + }; + + if element.group == 0x0002 && element.element == 0x0010 { + data_syntax = dicom_transfer_syntax(element.value); + } + + if let Some(name) = dicom_tag_name(element.group, element.element) { + if let Some(value) = dicom_value(&element, encoding) { + metadata.insert(name, value); + } + } + if element.next_offset <= offset { + break; + } + offset = element.next_offset; + } + + metadata.insert("File:FileType", TagValue::new_string("DICOM")); + metadata.insert("File:FileTypeExtension", TagValue::new_string("dcm")); + metadata.insert("File:MIMEType", TagValue::new_string("application/dicom")); + Ok(metadata) +} + +#[cfg(test)] +mod dicom_tests { + use super::*; + use crate::test_support::TestReader; + + fn dicom_file(elements: &[u8]) -> Vec { + let mut data = vec![0u8; DICOM_MAGIC_OFFSET]; + data.extend_from_slice(b"DICM"); + data.extend_from_slice(elements); + data + } + + #[test] + fn tm_conversion_requires_six_leading_digits() { + // DICOM.pm: s/^ *(\d{2})(\d{2})(\d{2}[^ ]*)/$1:$2:$3/ + assert_eq!(dicom_time_bytes(b"143415"), b"14:34:15".to_vec()); + assert_eq!(dicom_time_bytes(b"143415.5"), b"14:34:15.5".to_vec()); + assert_eq!(dicom_time_bytes(b" 143415"), b"14:34:15".to_vec()); + // Partial times are legal DICOM; ExifTool reports them verbatim. + assert_eq!(dicom_time_bytes(b"1434"), b"1434".to_vec()); + assert_eq!(dicom_time_bytes(b"14"), b"14".to_vec()); + } + + #[test] + fn non_utf8_time_value_is_omitted_not_a_panic() { + // Regression: the old text-based conversion sliced a lossy string at + // byte 6, which fell inside a replacement character and panicked. + assert_eq!( + dicom_string_value(*b"TM", b"1\xFF\xFF", ByteOrder::Little), + None + ); + assert_eq!( + dicom_string_value(*b"TM", b"143415", ByteOrder::Little), + Some("14:34:15".to_string()) + ); + } + + #[test] + fn da_conversion_is_prefix_anchored_like_exiftool() { + assert_eq!( + dicom_string_value(*b"DA", b" 20010316", ByteOrder::Little), + Some("2001:03:16".to_string()) + ); + assert_eq!( + dicom_string_value(*b"DA", b"20010316\\20010317", ByteOrder::Little), + Some("2001:03:16\\20010317".to_string()) + ); + assert_eq!( + dicom_string_value(*b"DA", b"2001031", ByteOrder::Little), + Some("2001031".to_string()) + ); + } + + #[test] + fn per_vr_trimming_matches_exiftool() { + // SH: leading and trailing spaces are not significant. + assert_eq!( + dicom_string_value(*b"SH", b" A123 ", ByteOrder::Little), + Some("A123".to_string()) + ); + // LT: trailing spaces only. + assert_eq!( + dicom_string_value(*b"LT", b" note ", ByteOrder::Little), + Some(" note".to_string()) + ); + // UI truncates at the first null; other VRs keep nulls. + assert_eq!( + dicom_string_value(*b"UI", b"1.2.840\0", ByteOrder::Little), + Some("1.2.840".to_string()) + ); + assert_eq!( + dicom_string_value(*b"SH", b"AB\0", ByteOrder::Little), + Some("AB\0".to_string()) + ); + // Default VRs get only the single even-length pad space removed. + assert_eq!( + dicom_string_value(*b"UN", b"ab ", ByteOrder::Little), + Some("ab ".to_string()) + ); + } + + #[test] + fn odd_length_int16u_ignores_the_stray_byte() { + assert_eq!(dicom_int16u(&[1, 0, 2, 0, 9], ByteOrder::Little), "1 2"); + assert_eq!(dicom_int16u(&[7], ByteOrder::Little), ""); + } + + #[test] + fn undefined_length_elements_are_walked_not_fatal() { + let mut body = Vec::new(); + // (0008,0022) DA "20010316" + body.extend_from_slice(&[0x08, 0x00, 0x22, 0x00]); + body.extend_from_slice(b"DA"); + body.extend_from_slice(&8u16.to_le_bytes()); + body.extend_from_slice(b"20010316"); + // (7FE0,0010) OB with undefined length: encapsulated pixel data. + body.extend_from_slice(&[0xE0, 0x7F, 0x10, 0x00]); + body.extend_from_slice(b"OB"); + body.extend_from_slice(&[0, 0]); + body.extend_from_slice(&u32::MAX.to_le_bytes()); + // item (FFFE,E000) with 4 bytes of fragment data (no VR field) + body.extend_from_slice(&[0xFE, 0xFF, 0x00, 0xE0]); + body.extend_from_slice(&4u32.to_le_bytes()); + body.extend_from_slice(&[1, 2, 3, 4]); + // sequence delimiter (FFFE,E0DD), zero length + body.extend_from_slice(&[0xFE, 0xFF, 0xDD, 0xE0]); + body.extend_from_slice(&0u32.to_le_bytes()); + // (0020,0012) IS "31763 " (space-padded to an even length) + body.extend_from_slice(&[0x20, 0x00, 0x12, 0x00]); + body.extend_from_slice(b"IS"); + body.extend_from_slice(&6u16.to_le_bytes()); + body.extend_from_slice(b"31763 "); + + let reader = TestReader::new(dicom_file(&body)); + let metadata = parse_dicom_metadata(&reader).unwrap(); + assert_eq!( + metadata.get("DICOM:AcquisitionDate"), + Some(&TagValue::String("2001:03:16".to_string())) + ); + // Extraction continues past the undefined-length element. + assert_eq!( + metadata.get("DICOM:AcquisitionNumber"), + Some(&TagValue::String("31763".to_string())) + ); + assert_eq!(metadata.get_string("File:FileType"), Some("DICOM")); + } + + #[test] + fn truncated_tail_keeps_tags_already_extracted() { + let mut body = Vec::new(); + // (0008,0032) TM "143415" + body.extend_from_slice(&[0x08, 0x00, 0x32, 0x00]); + body.extend_from_slice(b"TM"); + body.extend_from_slice(&6u16.to_le_bytes()); + body.extend_from_slice(b"143415"); + // A truncated element: the header promises more bytes than remain. + body.extend_from_slice(&[0x08, 0x00, 0x50, 0x00]); + body.extend_from_slice(b"SH"); + body.extend_from_slice(&64u16.to_le_bytes()); + body.extend_from_slice(b"abc"); + + let reader = TestReader::new(dicom_file(&body)); + let metadata = parse_dicom_metadata(&reader).unwrap(); + assert_eq!( + metadata.get("DICOM:AcquisitionTime"), + Some(&TagValue::String("14:34:15".to_string())) + ); + assert_eq!(metadata.get_string("File:FileType"), Some("DICOM")); + } + + #[test] + fn od_is_short_form_like_the_pinned_oracle() { + // ExifTool 13.59's %vr32 holds only OB/OW/OF/SQ/UT/UN, so OD (like + // OL/OV/UC/UR) uses the 8-byte header; matching that framing keeps + // the walk aligned with the oracle for everything that follows. + let mut body = Vec::new(); + body.extend_from_slice(&[0x18, 0x00, 0x00, 0x99]); // arbitrary tag + body.extend_from_slice(b"OD"); + body.extend_from_slice(&8u16.to_le_bytes()); + body.extend_from_slice(&[0u8; 8]); + body.extend_from_slice(&[0x08, 0x00, 0x22, 0x00]); + body.extend_from_slice(b"DA"); + body.extend_from_slice(&8u16.to_le_bytes()); + body.extend_from_slice(b"20010316"); + + let reader = TestReader::new(dicom_file(&body)); + let metadata = parse_dicom_metadata(&reader).unwrap(); + assert_eq!( + metadata.get("DICOM:AcquisitionDate"), + Some(&TagValue::String("2001:03:16".to_string())) + ); + } + + #[test] + fn deflated_transfer_syntax_stops_gracefully_after_file_meta() { + let mut body = Vec::new(); + // (0002,0010) UI TransferSyntaxUID = deflated explicit LE + body.extend_from_slice(&[0x02, 0x00, 0x10, 0x00]); + body.extend_from_slice(b"UI"); + body.extend_from_slice(&22u16.to_le_bytes()); + body.extend_from_slice(b"1.2.840.10008.1.2.1.99"); + // Compressed garbage follows; parsing it as elements would fabricate + // values, so the walk must stop while keeping the File tags. + body.extend_from_slice(&[ + 0x78, 0x9C, 0x08, 0x00, 0x22, 0x00, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, + ]); + + let reader = TestReader::new(dicom_file(&body)); + let metadata = parse_dicom_metadata(&reader).unwrap(); + assert_eq!(metadata.get_string("File:FileType"), Some("DICOM")); + assert!(!metadata.keys().any(|name| name.starts_with("DICOM:"))); + } + + #[test] + fn implicit_vr_uses_the_table_vr_for_conversion() { + let mut body = Vec::new(); + // (0002,0010) UI TransferSyntaxUID = implicit VR little endian + body.extend_from_slice(&[0x02, 0x00, 0x10, 0x00]); + body.extend_from_slice(b"UI"); + body.extend_from_slice(&18u16.to_le_bytes()); + body.extend_from_slice(b"1.2.840.10008.1.2\0"); + // Implicit-VR data element: (0008,0032) with a 32-bit length. + body.extend_from_slice(&[0x08, 0x00, 0x32, 0x00]); + body.extend_from_slice(&6u32.to_le_bytes()); + body.extend_from_slice(b"143415"); + + let reader = TestReader::new(dicom_file(&body)); + let metadata = parse_dicom_metadata(&reader).unwrap(); + assert_eq!( + metadata.get("DICOM:AcquisitionTime"), + Some(&TagValue::String("14:34:15".to_string())) + ); + } + + #[test] + fn parses_requested_tags_from_real_dicom_sample() { + if !crate::test_support::pinned_corpus_available() { + return; + } + let path = format!("{}/DICOM.dcm", crate::test_support::PINNED_CORPUS_ROOT); + let data = std::fs::read(path).expect("pinned DICOM sample should be readable"); + let metadata = parse_dicom_metadata(&crate::test_support::TestReader::new(data)) + .expect("pinned DICOM sample should parse"); + + assert_eq!( + metadata.get("DICOM:AccessionNumber"), + Some(&TagValue::String(String::new())) + ); + assert_eq!( + metadata.get("DICOM:AcquisitionDate"), + Some(&TagValue::String("2001:03:16".to_string())) + ); + assert_eq!( + metadata.get("DICOM:AcquisitionMatrix"), + Some(&TagValue::String("0 256 256 0".to_string())) + ); + assert_eq!( + metadata.get("DICOM:AcquisitionNumber"), + Some(&TagValue::String("31763".to_string())) + ); + assert_eq!( + metadata.get("DICOM:AcquisitionTime"), + Some(&TagValue::String("14:34:15".to_string())) + ); + assert_eq!( + metadata.get("DICOM:AdditionalPatientHistory"), + Some(&TagValue::String(String::new())) + ); + } +} #[cfg(test)] mod tests { use super::*; diff --git a/src/parsers/specialized/mod.rs b/src/parsers/specialized/mod.rs index 71d0d72d6..71cf80b29 100644 --- a/src/parsers/specialized/mod.rs +++ b/src/parsers/specialized/mod.rs @@ -8,6 +8,7 @@ pub mod dwg; pub mod dxf; pub mod evtx; +pub mod fit; pub mod fits; pub mod gltf; pub mod hdf5; @@ -25,6 +26,7 @@ pub mod x509; pub use dwg::DWGParser; pub use dxf::DXFParser; pub use evtx::EVTXParser; +pub use fit::FITParser; pub use fits::FITSParser; pub use gltf::GLTFParser; pub use hdf5::HDF5Parser; diff --git a/src/parsers/tiff/makernotes/samsung/stmn.rs b/src/parsers/tiff/makernotes/samsung/stmn.rs index c4b7942c1..6bea2b12e 100644 --- a/src/parsers/tiff/makernotes/samsung/stmn.rs +++ b/src/parsers/tiff/makernotes/samsung/stmn.rs @@ -78,7 +78,7 @@ pub fn is_stmn(data: &[u8]) -> bool { /// A block shorter than 12 bytes cannot match the regex, so it stays `1b` -- /// and `decode_binary_table` then simply yields no `PreviewImageStart`, which /// is what ExifTool's `ProcessBinaryData` does with a short block too. -fn is_binary_only(data: &[u8]) -> bool { +pub(crate) fn is_binary_only(data: &[u8]) -> bool { data.len() >= PREVIEW_START.end && data[PREVIEW_START].iter().all(|byte| *byte == 0) }