mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
LR-IMP-FORMATS: a Samsung panorama JPEG with its SEF trailer imports (#3350)
Some Samsung panoramas have no end-of-image marker: the scan data runs straight into the SEF trailer at the end of the file, so the header check refused them as truncated, and jpeg-decoder (DCT-scaled previews) and zune-jpeg both fail on the trailer's bytes after the scan. When the file ends with a valid SEF trailer (SEFT, a directory length that points at SEFH, a sane entry count, every entry's block inside the file before SEFH) and no EOI is reached, the image is read as the bytes before the earliest SEF block plus an EOI, for both the header and the decode. The cut must fall in scan data after the first SOS; a JPEG cut short without such a trailer is still refused.
This commit is contained in:
@@ -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,58 @@ 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) };
|
||||
if reaches_eoi(bytes) {
|
||||
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`. `None` unless every entry's block 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)?)?)?;
|
||||
if block.checked_add(le32(entry.checked_add(8)?)?)? > dir {
|
||||
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()));
|
||||
|
||||
@@ -160,6 +160,58 @@ 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> {
|
||||
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");
|
||||
// marker-like bytes: both decoders refuse them as a second frame header after the scan
|
||||
block.extend_from_slice(&[0xFF, 0xC0, 0, 0x11, 0xFF, 0xDA, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
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, 0xE0, 0x08]);
|
||||
dir.extend_from_slice(&back.to_le_bytes());
|
||||
dir.extend_from_slice(&(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));
|
||||
// still truncated: no trailer, offsets outside the file or past `SEFH`, no `SEFH`, 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());
|
||||
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
@@ -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-24
|
||||
|
||||
### Relative-date filters and smart albums follow the clock
|
||||
|
||||
Reference in New Issue
Block a user