diff --git a/ROADMAP.md b/ROADMAP.md index fc09e288..5702705a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/create/README.md b/crates/create/README.md index cd20f195..7cd68277 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -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 diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 6e325ef6..bbf93709 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -228,18 +228,31 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result { } 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 { 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(); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index e649fdf7..7651fd3c 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -346,6 +346,64 @@ fn icc_of(doc: &Document, image: &Dict) -> Option<(i64, Vec, Vec, 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) -> Vec { + 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.