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:
才倾梦华V 2026-10-11 03:46:22 +08:00 committed by GitHub
parent cac2e8bf41
commit 7c386fa4ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 84 additions and 11 deletions

View File

@ -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.

View File

@ -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

View File

@ -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();

View File

@ -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.