Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
565 changes: 565 additions & 0 deletions docs/plans/2026-08-02-preview-image-composite-plan.md

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions src/bin/tag-comparison/extraction/oxidex_extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,11 +867,13 @@ impl OxiDexExtractor {

if let Some((family, name)) = tag_key.split_once(':') {
let normalized_family = match family {
// ExifIFD, IFD0, IFD1, GPS, and InteropIFD tags are output as EXIF in
// comparison reports. Perl ExifTool outputs GPS tags as EXIF:GPSxxx,
// and groups the thumbnail (IFD1) and Interoperability (InteropIFD)
// sub-IFDs under the same top-level "EXIF" family by default.
"ExifIFD" | "IFD0" | "IFD1" | "GPS" | "InteropIFD" => "EXIF",
// ExifIFD, IFD0, IFD1, IFD2, GPS, and InteropIFD tags are output as
// EXIF in comparison reports. Perl ExifTool outputs GPS tags as
// EXIF:GPSxxx, and groups the thumbnail (IFD1), the Leica-preview
// directory (IFD2 -- see tiff_helpers.rs's parse_ifd2_preview_image),
// and Interoperability (InteropIFD) sub-IFDs under the same
// top-level "EXIF" family by default.
"ExifIFD" | "IFD0" | "IFD1" | "IFD2" | "GPS" | "InteropIFD" => "EXIF",
// MP4/QuickTime: ItemList and UserData → QuickTime for comparison
"ItemList" | "UserData" => "QuickTime",
// WebP tags map to RIFF family in ExifTool
Expand Down
307 changes: 307 additions & 0 deletions src/core/jpeg_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,20 @@ pub fn process_exif_segments(
tiff_offset,
metadata,
);

// Walk IFD1's next-IFD pointer to IFD2. Leica JPEGs carry a
// second, larger preview there under tag 0x0111/0x0117,
// named PreviewImageStart/PreviewImageLength (not
// StripOffsets/StripByteCounts - see Exif.pm:707-768).
// Offsets are TIFF-relative, so this uses `tiff_reader`
// directly, with no `tiff_base` to add.
crate::core::tiff_helpers::parse_ifd2_preview_image(
&tiff_reader,
ifd_offset,
tags.len(),
byte_order,
metadata,
);
}
}
}
Expand Down Expand Up @@ -485,6 +499,120 @@ pub fn process_app3_segments(segments: &[Segment], metadata: &mut MetadataMap) {
}
}

/// ExifTool.pm:7997-8127 — a preview JPEG embedded directly in APP2/APP3
/// (optionally continued into APP4/APP5), found by byte-pattern rather than
/// an offset/length pair. No IFD context means ExifTool's FoundTag defaults
/// the displayed group to File.
///
/// - APP2: payload optionally prefixed by the literal bytes `QVGA\0` or
/// `BGTH`, followed by `\xFF\xD8\xFF` and one of `\xDB`/`\xE0`/`\xE1`. The
/// prefix (if any) is stripped before storing. Emitted immediately unless
/// the next segment is also APP2, in which case consecutive APP2 payloads
/// accumulate (Samsung/HP/BenQ APP2 preview).
/// - APP3: payload starting `\xFF\xD8\xFF\xDB` (no prefix) is the preview in
/// full. Continues into APP4 if the immediately following segment is APP4
/// (Samsung S1060-style split preview).
/// - APP4: only relevant as a continuation of an APP3 preview in progress;
/// its payload is appended and the tag emitted unless the next segment is
/// APP5, in which case the preview continues accumulating there.
/// - APP5: only relevant as a further continuation of an APP3/APP4 preview
/// in progress (BenQ DC E1050); its payload is appended and the tag is
/// always emitted immediately -- there is no further continuation marker
/// past APP5.
///
/// # Arguments
///
/// * `segments` - Parsed JPEG segments
/// * `metadata` - MetadataMap to populate with `File:PreviewImage`
pub fn extract_direct_preview_image(segments: &[Segment], metadata: &mut MetadataMap) {
let mut preview: Option<Vec<u8>> = None;

for (index, segment) in segments.iter().enumerate() {
let next_marker = segments.get(index + 1).map(|s| s.marker);

match segment.marker {
APP2_MARKER => {
let mut matched_pattern = false;
for prefix in [&b""[..], b"QVGA\0", b"BGTH"] {
if let Some(rest) = segment.data.strip_prefix(prefix) {
if rest.starts_with(b"\xff\xd8\xff")
&& matches!(rest.get(3), Some(0xdb | 0xe0 | 0xe1))
{
preview = Some(rest.to_vec());
matched_pattern = true;
break;
}
}
}
// ExifTool.pm:7929-7997: APP2 also carries ICC_PROFILE, FPXR,
// MPF, InfiRay's IJPEG version header and an "urn:" (Apple
// HDR) payload, each handled by its own `elsif` branch ahead
// of the `elsif ($preview)` continuation fallback. None of
// those identifiers is preview continuation data, so a
// segment carrying one must not be appended to an
// in-progress preview even though it also fails the preview
// byte pattern above.
let is_other_app2_payload = !matched_pattern
&& (segment.data.starts_with(b"ICC_PROFILE\0")
|| segment.data.starts_with(b"FPXR\0")
|| segment.data.starts_with(b"MPF\0")
|| segment.data.get(4..10) == Some(b"IJPEG\0".as_slice())
|| segment.data.starts_with(b"urn:"));
if !matched_pattern && !is_other_app2_payload {
if let Some(existing) = preview.as_mut() {
existing.extend_from_slice(segment.data);
}
}
if preview.is_some() && next_marker != Some(APP2_MARKER) {
metadata.insert(
"File:PreviewImage",
TagValue::new_binary(preview.take().unwrap()),
);
}
}
APP3_MARKER => {
if segment.data.starts_with(b"\xff\xd8\xff\xdb") {
preview = Some(segment.data.to_vec());
}
if preview.is_some() && next_marker != Some(APP4_MARKER) {
metadata.insert(
"File:PreviewImage",
TagValue::new_binary(preview.take().unwrap()),
);
}
}
APP4_MARKER => {
// ExifTool.pm:8116-8127: "continued Samsung S1060 preview
// from APP3" -- appended unconditionally, but only emitted
// if the *next* segment isn't APP5 ("BenQ DC E1050 continues
// preview in APP5").
if let Some(existing) = preview.as_mut() {
existing.extend_from_slice(segment.data);
}
if preview.is_some() && next_marker != Some(APP5_MARKER) {
metadata.insert(
"File:PreviewImage",
TagValue::new_binary(preview.take().unwrap()),
);
}
}
APP5_MARKER => {
// ExifTool.pm:8146-8151 (BenQ DC E1050 continuing a preview
// from APP4 into APP5): appends and emits unconditionally in
// the same statement -- unlike APP2/APP3/APP4, there is no
// further "does the run continue" check; APP5 is always the
// last segment of this preview mechanism. Only relevant as a
// continuation of an already-in-progress preview.
if let Some(mut existing) = preview.take() {
existing.extend_from_slice(segment.data);
metadata.insert("File:PreviewImage", TagValue::new_binary(existing));
}
}
_ => {}
}
}
}

/// Processes MPF (Multi-Picture Format) APP2 segments.
///
/// MPF is used in dual-camera phones and 3D cameras to store multiple images
Expand Down Expand Up @@ -1337,6 +1465,185 @@ mod tests {

assert!(!metadata.contains_key("APP15:Quality"));
}

/// Mirrors `create_jpeg_with_multiple_segments` in
/// `segment_parser.rs` (SOI, marker bytes, big-endian u16 length
/// including the length field itself, payload, EOI).
fn build_jpeg_with_app3_preview(payload: &[u8]) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&[0xFF, 0xD8]); // SOI
data.extend_from_slice(&[0xFF, 0xE3]); // APP3
data.extend_from_slice(&((payload.len() + 2) as u16).to_be_bytes());
data.extend_from_slice(payload);
data.extend_from_slice(&[0xFF, 0xD9]); // EOI
data
}

fn build_jpeg_with_app2_segment(payload: &[u8]) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&[0xFF, 0xD8]); // SOI
data.extend_from_slice(&[0xFF, 0xE2]); // APP2
data.extend_from_slice(&((payload.len() + 2) as u16).to_be_bytes());
data.extend_from_slice(payload);
data.extend_from_slice(&[0xFF, 0xD9]); // EOI
data
}

#[test]
fn app3_preview_dump_is_extracted_as_file_preview_image() {
let preview_payload = b"\xff\xd8\xff\xdb\x00\x43FAKEDATA";
let jpeg = build_jpeg_with_app3_preview(preview_payload);
let reader = crate::io::buffered_reader::BufferedReader::from_bytes(&jpeg);
let segments = crate::parsers::jpeg::segment_parser::parse_segments(&reader).unwrap();
let mut metadata = MetadataMap::new();

extract_direct_preview_image(&segments, &mut metadata);

assert_eq!(
metadata.get("File:PreviewImage"),
Some(&TagValue::new_binary(preview_payload.to_vec()))
);
}

#[test]
fn app2_preview_dump_strips_qvga_prefix() {
// Segment payload: "QVGA\0" + \xFF\xD8\xFF\xE0 + fake JPEG bytes.
// The QVGA\0 prefix must NOT be part of the stored PreviewImage.
let inner = b"\xff\xd8\xff\xe0FAKE2";
let mut payload = b"QVGA\0".to_vec();
payload.extend_from_slice(inner);
let jpeg = build_jpeg_with_app2_segment(&payload);
let reader = crate::io::buffered_reader::BufferedReader::from_bytes(&jpeg);
let segments = crate::parsers::jpeg::segment_parser::parse_segments(&reader).unwrap();
let mut metadata = MetadataMap::new();

extract_direct_preview_image(&segments, &mut metadata);

assert_eq!(
metadata.get("File:PreviewImage"),
Some(&TagValue::new_binary(inner.to_vec()))
);
}

/// General-purpose multi-segment JPEG builder: `(marker byte, payload)`
/// pairs, SOI-prefixed and EOI-terminated, following the same
/// length-field convention as `build_jpeg_with_app3_preview` above and
/// `create_jpeg_with_multiple_segments` in `segment_parser.rs`.
fn build_jpeg_with_segments(segments: &[(u8, &[u8])]) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&[0xFF, 0xD8]); // SOI
for (marker, payload) in segments {
data.extend_from_slice(&[0xFF, *marker]);
data.extend_from_slice(&((payload.len() + 2) as u16).to_be_bytes());
data.extend_from_slice(payload);
}
data.extend_from_slice(&[0xFF, 0xD9]); // EOI
data
}

/// ExifTool.pm:7997-8010's `elsif ($preview) { $preview .= $segDataPt }`
/// fallback: an APP2 preview run isn't limited to one segment. This is a
/// regression test for a bug found during corpus verification, where an
/// earlier version of this function only ever kept the first matching
/// APP2 segment and dropped every continuation segment, truncating
/// multi-segment previews (observed for real on GoProHERO.jpg, whose
/// true 340142-byte preview was cut to the first segment's 65533 bytes).
#[test]
fn app2_preview_dump_accumulates_across_consecutive_segments() {
let first = b"\xff\xd8\xff\xdb\x00\x43CHUNKONE";
let second = b"CHUNKTWO-CONTINUATION";
let jpeg = build_jpeg_with_segments(&[(0xE2, first), (0xE2, second)]);
let reader = crate::io::buffered_reader::BufferedReader::from_bytes(&jpeg);
let segments = crate::parsers::jpeg::segment_parser::parse_segments(&reader).unwrap();
let mut metadata = MetadataMap::new();

extract_direct_preview_image(&segments, &mut metadata);

let mut expected = first.to_vec();
expected.extend_from_slice(second);
assert_eq!(
metadata.get("File:PreviewImage"),
Some(&TagValue::new_binary(expected))
);
}

/// A segment carrying one of APP2's other known identifiers
/// (ICC_PROFILE\0/FPXR\0/MPF\0/`....IJPEG\0`/urn:) must not be folded
/// into an in-progress preview even though it also fails the preview
/// byte pattern -- ExifTool's `elsif` chain routes it to that
/// identifier's own branch instead of the `elsif ($preview)`
/// continuation fallback (ExifTool.pm:7929-7997).
#[test]
fn app2_preview_dump_does_not_absorb_mpf_segment() {
let preview_start = b"\xff\xd8\xff\xdb\x00\x43ONLYCHUNK";
let mpf_payload = b"MPF\0NOTPREVIEWDATA";
let jpeg = build_jpeg_with_segments(&[(0xE2, preview_start), (0xE2, mpf_payload)]);
let reader = crate::io::buffered_reader::BufferedReader::from_bytes(&jpeg);
let segments = crate::parsers::jpeg::segment_parser::parse_segments(&reader).unwrap();
let mut metadata = MetadataMap::new();

extract_direct_preview_image(&segments, &mut metadata);

// The MPF segment ends the APP2 run without being appended: the
// stored preview is exactly the first segment's bytes.
assert_eq!(
metadata.get("File:PreviewImage"),
Some(&TagValue::new_binary(preview_start.to_vec()))
);
}

/// ExifTool.pm:8116-8127 (Samsung S1060): a preview started in APP3
/// continues into APP4, and is only emitted once the segment after APP4
/// is not itself APP5.
#[test]
fn app3_preview_dump_continues_into_app4() {
let app3_payload = b"\xff\xd8\xff\xdbAPP3PART";
let app4_payload = b"APP4CONTINUATION";
let jpeg = build_jpeg_with_segments(&[(0xE3, app3_payload), (0xE4, app4_payload)]);
let reader = crate::io::buffered_reader::BufferedReader::from_bytes(&jpeg);
let segments = crate::parsers::jpeg::segment_parser::parse_segments(&reader).unwrap();
let mut metadata = MetadataMap::new();

extract_direct_preview_image(&segments, &mut metadata);

let mut expected = app3_payload.to_vec();
expected.extend_from_slice(app4_payload);
assert_eq!(
metadata.get("File:PreviewImage"),
Some(&TagValue::new_binary(expected))
);
}

/// ExifTool.pm:8116-8151 (BenQ DC E1050): APP3 -> APP4 -> APP5, where
/// APP4 must NOT emit early because the next segment is APP5, and APP5
/// appends its payload and emits unconditionally. This is the
/// regression test for the APP4/APP5 gating bug: an earlier version of
/// this function emitted (and truncated) the preview at APP4 because it
/// never checked whether APP5 followed.
#[test]
fn app3_preview_dump_continues_through_app4_into_app5() {
let app3_payload = b"\xff\xd8\xff\xdbAPP3PART";
let app4_payload = b"APP4PART";
let app5_payload = b"APP5PART-FINAL";
let jpeg = build_jpeg_with_segments(&[
(0xE3, app3_payload),
(0xE4, app4_payload),
(0xE5, app5_payload),
]);
let reader = crate::io::buffered_reader::BufferedReader::from_bytes(&jpeg);
let segments = crate::parsers::jpeg::segment_parser::parse_segments(&reader).unwrap();
let mut metadata = MetadataMap::new();

extract_direct_preview_image(&segments, &mut metadata);

let mut expected = app3_payload.to_vec();
expected.extend_from_slice(app4_payload);
expected.extend_from_slice(app5_payload);
assert_eq!(
metadata.get("File:PreviewImage"),
Some(&TagValue::new_binary(expected))
);
}
}

#[cfg(test)]
Expand Down
14 changes: 9 additions & 5 deletions src/core/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
use super::{FileFormat, FileReader, MetadataMap, TagValue};
use crate::core::format_dispatch::dispatch_format_parser;
use crate::core::jpeg_helpers::{
process_app3_segments, process_app6_segments, process_app10_segments, process_app11_segments,
process_app12_segments, process_app14_segments, process_app15_segments, process_com_segments,
process_dqt_segments, process_exif_segments, process_icc_segments, process_infiray_segments,
process_iptc_segments, process_jfif_segments, process_mpf_segments, process_photoshop_segments,
process_qualcomm_segments, process_sof_segments, process_spiff_segments, process_xmp_segments,
extract_direct_preview_image, process_app3_segments, process_app6_segments,
process_app10_segments, process_app11_segments, process_app12_segments, process_app14_segments,
process_app15_segments, process_com_segments, process_dqt_segments, process_exif_segments,
process_icc_segments, process_infiray_segments, process_iptc_segments, process_jfif_segments,
process_mpf_segments, process_photoshop_segments, process_qualcomm_segments,
process_sof_segments, process_spiff_segments, process_xmp_segments,
};
use crate::core::operations_helpers::{read_u16, read_u32};
#[cfg(test)]
Expand Down Expand Up @@ -810,6 +811,9 @@ pub(crate) fn parse_jpeg_metadata(reader: &dyn FileReader) -> Result<MetadataMap

// Process HDR and manufacturer-specific APP segments
process_app3_segments(&segments, &mut metadata);
// Samsung/HP/BenQ/GoPro/Rollei preview JPEG found by byte pattern
// directly in APP2/APP3(/APP4) payload (ExifTool.pm:7997-8127).
extract_direct_preview_image(&segments, &mut metadata);
// InfiRay IJPEG spreads its records over APP2-APP9; APP6 and APP8 are read
// by the two calls that already own those markers.
process_infiray_segments(&segments, &mut metadata);
Expand Down
Loading
Loading