mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
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:
@@ -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()));
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user