Merge PR #3375: LR-IMP-FORMATS: a Samsung panorama JPEG with its SEF trailer imports

docs/whats-new.md: the PR's 2026-10-30 section added above main's sections.
This commit is contained in:
aiongg
2026-10-31 02:11:48 +00:00
4 changed files with 132 additions and 1 deletions
+56
View File
@@ -131,6 +131,7 @@ const MAX_APP1: u32 = 128;
/// type or component count, or when its markers don't run to an end-of-image marker after the
/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let bytes = &*without_sef_trailer(bytes);
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
@@ -177,7 +178,62 @@ fn reaches_eoi(b: &[u8]) -> bool {
}
}
/// A Samsung panorama whose scan runs straight into its SEF trailer, with no EOI marker (issue
/// #3350), as the file it stands for: the bytes before the trailer's earliest data block plus an
/// EOI. Every other file, a genuinely truncated one included, is borrowed unchanged.
fn without_sef_trailer(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> {
use std::borrow::Cow;
// The trailer is checked first: it reads only the file's last bytes, not the scan data.
let Some(image) = sef_image_end(bytes).and_then(|end| bytes.get(..end)) else { return Cow::Borrowed(bytes) };
// the image before the trailer: an FF D9 inside the SEF data doesn't end the scan
if reaches_eoi(image) {
return Cow::Borrowed(bytes);
}
let mut fixed = Vec::with_capacity(image.len().saturating_add(2));
fixed.extend_from_slice(image);
fixed.extend_from_slice(&[0xFF, 0xD9]);
// The cut must leave the scan data, not a segment or anything before the first scan, open.
let in_scan = parse_markers(&fixed).is_some_and(|m| m.first_scan_components > 0) && reaches_eoi(&fixed);
if in_scan { Cow::Owned(fixed) } else { Cow::Borrowed(bytes) }
}
/// Where the image ends in a file carrying a Samsung SEF trailer: the start of its earliest data
/// block. The trailer is the file's last bytes: `SEFH`, a u32 LE version and entry count, 12-byte
/// entries (u16 padding, u16 type, u32 LE offset of the block back from `SEFH`, u32 LE length),
/// then the u32 LE length of that directory and `SEFT`. Each block starts with the same padding
/// and type as its entry. `None` unless every entry's block is nonempty, starts with its entry's
/// type and lies inside the file, before `SEFH`.
fn sef_image_end(b: &[u8]) -> Option<usize> {
let le32 = |at: usize| b.get(at..at.checked_add(4)?)?.try_into().ok().map(|v| u32::from_le_bytes(v) as usize);
let tail = b.len().checked_sub(8)?;
if b.get(tail.checked_add(4)?..)? != b"SEFT" {
return None;
}
let dir = tail.checked_sub(le32(tail)?)?;
if b.get(dir..dir.checked_add(4)?)? != b"SEFH" {
return None;
}
let count = le32(dir.checked_add(8)?)?;
let entries = dir.checked_add(12)?;
if count == 0 || count > tail.checked_sub(entries)? / 12 {
return None;
}
let mut start = dir;
for i in 0..count {
let entry = entries.checked_add(i.checked_mul(12)?)?;
let block = dir.checked_sub(le32(entry.checked_add(4)?)?)?;
let len = le32(entry.checked_add(8)?)?;
let kind = |at: usize| b.get(at.checked_add(2)?..at.checked_add(4)?);
if len < 4 || block.checked_add(len)? > dir || kind(block)? != kind(entry)? {
return None;
}
start = start.min(block);
}
Some(start)
}
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
let bytes = &*without_sef_trailer(bytes);
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
+66
View File
@@ -160,6 +160,72 @@ fn truncation_details() {
assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated");
}
/// A Samsung SEF trailer as the end of `image`: one `Panorama_Shot_Info` data block, then the
/// `SEFH` directory with one entry (type 0x08E0) whose offset counts back from `SEFH` by `back`,
/// the directory's length and `SEFT`.
fn with_sef_trailer(image: &[u8], back: u32, magic: &[u8; 4]) -> Vec<u8> {
// marker-like bytes: both decoders refuse them as a second frame header after the scan
sef_file(image, back, magic, 0x08E0, &[0xFF, 0xC0, 0, 0x11, 0xFF, 0xDA, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], None)
}
/// [`with_sef_trailer`] with the entry's type, the block's data after its name, and the length
/// the entry gives (`None`: the block's own).
fn sef_file(image: &[u8], back: u32, magic: &[u8; 4], kind: u16, data: &[u8], len: Option<u32>) -> Vec<u8> {
let mut b = image.to_vec();
let mut block = vec![0, 0, 0xE0, 0x08];
block.extend_from_slice(&18u32.to_le_bytes());
block.extend_from_slice(b"Panorama_Shot_Info");
block.extend_from_slice(data);
b.extend_from_slice(&block);
let mut dir = magic.to_vec();
dir.extend_from_slice(&106u32.to_le_bytes());
dir.extend_from_slice(&1u32.to_le_bytes());
dir.extend_from_slice(&[0, 0]);
dir.extend_from_slice(&kind.to_le_bytes());
dir.extend_from_slice(&back.to_le_bytes());
dir.extend_from_slice(&len.unwrap_or(block.len() as u32).to_le_bytes());
let len = dir.len() as u32;
b.extend_from_slice(&dir);
b.extend_from_slice(&len.to_le_bytes());
b.extend_from_slice(b"SEFT");
b
}
/// Issue #3350: a Samsung panorama has no EOI; its scan data runs into the SEF trailer. It reads
/// and decodes (both decoders: zune-jpeg in full, jpeg-decoder DCT-scaled) as the image before the
/// trailer, and a file cut short with no trailer, or with a broken one, is still truncated.
#[test]
fn samsung_sef_trailer_without_eoi() {
let jpeg = fixtures(160, 96).into_iter().find(|x| x.0 == "jpeg 420 o6").map(|x| x.1).unwrap();
let scan = &jpeg[..jpeg.len() - 2];
let block = 4 + 4 + 18 + 18;
let pano = with_sef_trailer(scan, block, b"SEFH");
let header = read_header(&pano).unwrap();
assert_eq!((header.width, header.height, header.orientation), (160, 96, 6));
let full = decode(&pano, DecodeOptions::default()).unwrap();
assert_eq!((full.source_width, full.source_height), (160, 96));
let reference = decode(&jpeg, DecodeOptions::default()).unwrap();
assert_eq!(full.image.data, reference.image.data, "the same pixels as the file with its EOI");
let small = decode(&pano, DecodeOptions::fit(32, 32)).unwrap();
assert_eq!((small.source_width, small.source_height), (160, 96));
// an FF D9 inside the SEF data is not the image's end
let eoi_in_data = sef_file(scan, block, b"SEFH", 0x08E0, &[0xFF, 0xC0, 0, 0x11, 0xFF, 0xD9, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], None);
assert_eq!(read_header(&eoi_in_data).unwrap().width, 160);
let small = decode(&eoi_in_data, DecodeOptions::fit(32, 32)).unwrap();
assert_eq!((small.source_width, small.source_height), (160, 96));
// still truncated: no trailer, offsets outside the file or past `SEFH`, no `SEFH`, an empty
// entry, an entry whose type isn't its block's, a cut before the first scan
assert!(read_header(scan).is_err());
assert!(read_header(&with_sef_trailer(scan, u32::MAX, b"SEFH")).is_err());
assert!(read_header(&with_sef_trailer(scan, block - 1, b"SEFH")).is_err());
assert!(read_header(&with_sef_trailer(scan, block, b"SEFX")).is_err());
assert!(read_header(&sef_file(scan, 0, b"SEFH", 0x08E0, &[], Some(0))).is_err());
assert!(read_header(&sef_file(scan, block, b"SEFH", 0x0A01, &[0; 18], None)).is_err());
let cut_in_headers = with_sef_trailer(&jpeg[..40], block, b"SEFH");
assert!(read_header(&cut_in_headers).is_err());
assert!(decode(&cut_in_headers, DecodeOptions::fit(32, 32)).is_err());
}
/// eXIf after the image data counts, as it does for a decode (which reads the whole file).
#[test]
fn png_exif_after_idat() {
File diff suppressed because one or more lines are too long
+9
View File
@@ -1,5 +1,14 @@
# What's new in LightCraft
## 2026-10-30
### Samsung panoramas import
- Some panoramas from Samsung phones were refused as truncated ("the image data doesn't end with an end-of-image
marker"), although other apps open them whole: their image data runs straight into the Samsung (SEF) block at the
end of the file, with no end-of-image marker in between. When the file ends with a valid SEF block, the image now
ends where that block begins, so these photos import, show and build previews. A JPEG that is really cut short is
still refused.
## 2026-10-29
### Move Folder To… no longer freezes the window