M10.2: scan JPEG metadata beyond frame headers (#764)
Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
cac2e8bf41
commit
7c386fa4ac
|
|
@ -216,6 +216,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
|||
## Log
|
||||
Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged.
|
||||
- **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged.
|
||||
|
||||
- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
|
|||
(`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate,
|
||||
with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG
|
||||
`iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with
|
||||
the device space as `/Alternate`; images with the same profile share one profile object;
|
||||
the device space as `/Alternate`; images with the same profile share one profile object.
|
||||
JPEG metadata scanning skips marker fill bytes and reads application segments after the
|
||||
frame header, stopping at the first scan or end marker. Broken or mismatched ICC profiles
|
||||
still fall back to the device colour space;
|
||||
- `from_images_with_resolution(images, ImageResolution::Dpi(dpi))`: override the page
|
||||
resolution (1–1200 dpi), without resampling or recompressing image pixels. Use 72 dpi
|
||||
for one point per pixel, or `ImageResolution::Embedded` for the same behavior as
|
||||
|
|
|
|||
|
|
@ -228,18 +228,31 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
|
|||
}
|
||||
let (mut i, mut size, mut comps, mut dpi, mut adobe) = (2usize, None, 0u8, (72.0, 72.0), false);
|
||||
let mut icc_chunks = Vec::new();
|
||||
while i + 4 <= bytes.len() {
|
||||
if bytes[i] != 0xFF {
|
||||
while let Some(&byte) = bytes.get(i) {
|
||||
if byte != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = bytes[i + 1];
|
||||
i += 1;
|
||||
// Markers may be preceded by any number of 0xFF fill bytes.
|
||||
while bytes.get(i) == Some(&0xFF) {
|
||||
i += 1;
|
||||
}
|
||||
let marker = *bytes.get(i).ok_or_else(|| bad("truncated JPEG marker"))?;
|
||||
i += 1;
|
||||
// Metadata ends at the first scan; entropy-coded bytes are not segments.
|
||||
if marker == 0xDA || marker == 0xD9 {
|
||||
break;
|
||||
}
|
||||
if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
|
||||
let seg = bytes.get(i + 4..i + 2 + len).ok_or_else(|| bad("truncated"))?;
|
||||
let len = usize::from(be16(bytes, i).ok_or_else(|| bad("truncated JPEG segment length"))?);
|
||||
if len < 2 {
|
||||
return Err(bad("invalid JPEG segment length"));
|
||||
}
|
||||
let end = i.checked_add(len).ok_or_else(|| bad("JPEG segment length overflow"))?;
|
||||
let seg = bytes.get(i + 2..end).ok_or_else(|| bad("truncated JPEG segment"))?;
|
||||
match marker {
|
||||
// APP0 JFIF density.
|
||||
0xE0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => {
|
||||
|
|
@ -259,18 +272,16 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
|
|||
icc_chunks.push((*seq, *count, data));
|
||||
}
|
||||
}
|
||||
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => {
|
||||
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && size.is_none() => {
|
||||
if seg.len() < 6 {
|
||||
return Err(bad("bad frame header"));
|
||||
}
|
||||
size = Some((u16::from_be_bytes([seg[3], seg[4]]) as u32, u16::from_be_bytes([seg[1], seg[2]]) as u32));
|
||||
comps = seg[5];
|
||||
break;
|
||||
}
|
||||
0xDA => break,
|
||||
_ => {}
|
||||
}
|
||||
i += 2 + len;
|
||||
i = end;
|
||||
}
|
||||
let (w, h) = size.filter(|(w, h)| *w > 0 && *h > 0).ok_or_else(|| bad("no image size"))?;
|
||||
let mut d = Dict::new();
|
||||
|
|
|
|||
|
|
@ -346,6 +346,64 @@ fn icc_of(doc: &Document, image: &Dict) -> Option<(i64, Vec<u8>, Vec<u8>, Object
|
|||
Some((s.dict.int(b"N")?, s.dict.name(b"Alternate")?.to_vec(), s.decoded().unwrap(), r))
|
||||
}
|
||||
|
||||
/// Move the synthetic fixture's frame before its application segments.
|
||||
fn jpeg_frame_first(mut bytes: Vec<u8>) -> Vec<u8> {
|
||||
let at = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap();
|
||||
let len = usize::from(u16::from_be_bytes([bytes[at + 2], bytes[at + 3]]));
|
||||
let frame: Vec<_> = bytes.drain(at..at + 2 + len).collect();
|
||||
bytes.splice(2..2, frame);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_fill_bytes_and_metadata_after_frame_are_preserved() {
|
||||
let profile = icc_profile(b"CMYK");
|
||||
let mut bytes = jpeg_frame_first(cmyk_jpeg_bytes(&profile));
|
||||
// Fill before SOF and APP14, including multiple consecutive fill bytes.
|
||||
bytes.splice(24..24, [0xFF, 0xFF]);
|
||||
bytes.insert(2, 0xFF);
|
||||
let doc = reopen(&from_images(&[("filled.jpg".into(), bytes.clone())]).unwrap());
|
||||
let image = image_of(&doc, 0);
|
||||
let (n, alt, data, _) = icc_of(&doc, &image).unwrap();
|
||||
assert_eq!((n, alt.as_slice(), data), (4, &b"DeviceCMYK"[..], profile));
|
||||
assert!(image.contains(b"Decode"), "APP14 after SOF still inverts CMYK");
|
||||
assert_eq!(extract_images(&doc, &[0], 0).images[0].data, bytes);
|
||||
|
||||
let embedded = jpeg("density.jpg", &jpeg_frame_first(jpeg_bytes())).unwrap();
|
||||
assert_eq!(embedded.px, (3, 2));
|
||||
assert_eq!(embedded.dpi, (300.0, 300.0), "JFIF after SOF is read");
|
||||
|
||||
// A mismatched profile found after SOF still falls back leniently, as in #729.
|
||||
let bytes = jpeg_frame_first(cmyk_jpeg_bytes(&icc_profile(b"RGB ")));
|
||||
let doc = reopen(&from_images(&[("mismatch.jpg".into(), bytes)]).unwrap());
|
||||
assert_eq!(image_of(&doc, 0).name(b"ColorSpace"), Some(&b"DeviceCMYK"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_segment_scan_stops_at_sos_or_eoi() {
|
||||
let profile = icc_profile(b"CMYK");
|
||||
for marker in [0xDA, 0xD9] {
|
||||
let mut bytes = jpeg_frame_first(cmyk_jpeg_bytes(&profile));
|
||||
bytes.truncate(bytes.len() - 2);
|
||||
// Neither marker needs a segment length to stop metadata scanning. The trailing
|
||||
// invalid APP2 would fail if entropy data or bytes after EOI were walked.
|
||||
bytes.extend_from_slice(&[0xFF, 0xFF, marker, 0xFF, 0xE2, 0, 1]);
|
||||
let embedded = jpeg("boundary.jpg", &bytes).unwrap();
|
||||
assert_eq!(embedded.icc, Some(profile.clone()));
|
||||
assert_eq!(embedded.px, (3, 2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_segment_scan_rejects_truncated_or_invalid_lengths() {
|
||||
for tail in [&[0xFF][..], &[0xFF, 0xFF], &[0xFF, 0xE2], &[0xFF, 0xE2, 0], &[0xFF, 0xE2, 0, 0], &[0xFF, 0xE2, 0, 1], &[0xFF, 0xE2, 0, 3]] {
|
||||
let mut bytes = jpeg_bytes();
|
||||
bytes.truncate(bytes.len() - 2);
|
||||
bytes.extend_from_slice(tail);
|
||||
assert!(jpeg("truncated.jpg", &bytes).is_err(), "tail: {tail:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_icc_profiles_tag_the_image_colour_space() {
|
||||
// A CMYK JPEG keeps its profile as /ICCBased (N 4), its data byte for byte and its Decode.
|
||||
|
|
|
|||
Loading…
Reference in New Issue