fix(codecs): validate JPEG ICC APP2 chunk sequences

This commit is contained in:
ronibalthazar
2026-10-25 14:13:58 +00:00
committed by Roni Balthazar
parent 03c88c166c
commit 0352b420a7
8 changed files with 312 additions and 27 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
| Format | Decode | Encode | Notes |
|---|---|---|---|
| JPEG | yes (zune-jpeg; jpeg-decoder for DCT scaling, CMYK/YCCK, 12-bit, non-interleaved scans) | yes (jpeg-encoder, baseline, 4:4:4/4:2:2/4:2:0) | EXIF (APP1), XMP (APP1, ≤ 64 KiB), ICC (APP2, multi-chunk), Adobe APP14, MPF previews |
| JPEG | yes (zune-jpeg; jpeg-decoder for DCT scaling, CMYK/YCCK, 12-bit, non-interleaved scans) | yes (jpeg-encoder, baseline, 4:4:4/4:2:2/4:2:0) | EXIF (APP1), XMP (APP1, ≤ 64 KiB), ICC (APP2; only complete, consistently counted, uniquely numbered chunk sequences are assembled; malformed profiles use the existing unsupported-profile fallback), Adobe APP14, MPF previews |
| PNG | yes, 1–16-bit, palette, tRNS, interlaced | 8/16-bit, gray/GA/RGB/RGBA | iCCP, eXIf, iTXt XMP; sRGB/cICP/cHRM/gAMA honoured when no ICC |
| TIFF | 8/16-bit int (32/64-bit int kept to 16 bits), 16/32/64-bit float, gray/RGB/CMYK/palette, alpha (assoc./unassoc.), planar, LZW/Deflate/PackBits/JPEG | 8/16-bit, 32-bit float; none/LZW/Deflate/PackBits | ICC (34675), XMP (700), Orientation; first IFD only; EXIF not written (use `lightcraft-tiff`) |
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
+6 -4
View File
@@ -78,6 +78,8 @@ pub(crate) struct Raw {
#[derive(Default)]
pub(crate) struct Meta {
pub icc: Option<Vec<u8>>,
/// An ICC profile was declared by the container but its sequence was invalid.
pub icc_invalid: bool,
pub exif: Option<Vec<u8>>,
pub xmp: Option<String>,
/// Orientation from the container itself (e.g. TIFF tag); EXIF blob is consulted otherwise.
@@ -120,7 +122,7 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3
let (image, space) = if let Some(space) = meta.already_linear.clone() {
(to_rgb_passthrough(&raw, ch), space)
} else if raw.model == Model::Cmyk {
cmyk(&raw, ch, meta.icc.as_deref(), parsed_icc.as_ref())
cmyk(&raw, ch, meta.icc.as_deref(), parsed_icc.as_ref(), meta.icc_invalid)
} else {
rgb_or_gray(&raw, ch, &meta, parsed_icc.as_ref(), float)
};
@@ -299,7 +301,7 @@ fn rgb_or_gray(raw: &Raw, ch: usize, meta: &Meta, info: Option<&icc::IccInfo>, f
_ => {}
}
}
if meta.icc.is_some() {
if meta.icc.is_some() || meta.icc_invalid {
// Present but unusable (malformed, mismatched model, CMS failure): assume sRGB.
let s = SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::IccUnsupported);
let t = NamedSpace::Srgb.trc();
@@ -323,7 +325,7 @@ fn rgb_or_gray(raw: &Raw, ch: usize, meta: &Meta, info: Option<&icc::IccInfo>, f
(linearize(raw, ch, &t), s)
}
fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccInfo>) -> (Rgb32f, SourceSpace) {
fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccInfo>, icc_invalid: bool) -> (Rgb32f, SourceSpace) {
let samples = device_samples(raw, ch, 4);
if let (Some(bytes), Some(info)) = (icc_bytes, info)
&& info.model == IccColorModel::Cmyk
@@ -343,6 +345,6 @@ fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccIn
[(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear)
})
.collect();
let origin = if icc_bytes.is_some() { SpaceOrigin::IccUnsupported } else { SpaceOrigin::Naive };
let origin = if icc_bytes.is_some() || icc_invalid { SpaceOrigin::IccUnsupported } else { SpaceOrigin::Naive };
(from_vec(raw.width, raw.height, px), SourceSpace::named(NamedSpace::Srgb, origin))
}
+166 -12
View File
@@ -24,6 +24,8 @@ pub(crate) struct Markers {
pub exif_offset: usize,
pub xmp: Option<String>,
pub icc: Option<Vec<u8>>,
/// An ICC_PROFILE APP2 segment was present but its sequence was invalid.
pub icc_invalid: bool,
pub adobe_transform: Option<u8>,
/// Offset of the MPF TIFF header within the file and its bytes.
pub mpf: Option<(usize, Vec<u8>)>,
@@ -45,7 +47,8 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
return None;
}
let mut m = Markers::default();
let mut icc_chunks: Vec<(u8, &[u8])> = Vec::new();
let mut icc_chunks: [Option<&[u8]>; 255] = [None; 255];
let mut icc_total = None;
let mut p = 2;
while p + 4 <= b.len() {
if b[p] != 0xFF {
@@ -73,9 +76,11 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
if len < 2 {
return None;
}
let start = p + 4;
let end = (p + 2 + len).min(b.len());
let seg = &b[start..end];
let start = p.checked_add(4)?;
let declared_end = p.checked_add(2)?.checked_add(len)?;
let complete = declared_end <= b.len();
let end = declared_end.min(b.len());
let seg = b.get(start..end)?;
match marker {
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => {
m.precision = seg[0];
@@ -96,8 +101,30 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
}
}
0xE2 => {
if seg.starts_with(b"ICC_PROFILE\0") && seg.len() >= 14 {
icc_chunks.push((seg[12], &seg[14..]));
if seg.starts_with(b"ICC_PROFILE\0") {
if !complete || seg.len() < 14 {
m.icc_invalid = true;
} else if let (Some(&sequence), Some(&total)) = (seg.get(12), seg.get(13)) {
if sequence == 0 || total == 0 || sequence > total || icc_total.is_some_and(|expected| expected != total) {
m.icc_invalid = true;
} else {
icc_total = Some(total);
let Some(slot) = icc_chunks.get_mut(usize::from(sequence - 1)) else {
m.icc_invalid = true;
p = declared_end;
continue;
};
if slot.is_some() {
m.icc_invalid = true;
} else if let Some(data) = seg.get(14..) {
*slot = Some(data);
} else {
m.icc_invalid = true;
}
}
} else {
m.icc_invalid = true;
}
} else if seg.starts_with(b"MPF\0") && m.mpf.is_none() {
m.mpf = Some((start + 4, seg[4..].to_vec()));
} else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN)
@@ -111,11 +138,32 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
}
_ => {}
}
p += 2 + len;
p = declared_end;
}
if !icc_chunks.is_empty() {
icc_chunks.sort_by_key(|c| c.0);
m.icc = Some(icc_chunks.iter().flat_map(|c| c.1.iter().copied()).collect());
if let Some(total) = icc_total {
let mut profile_len = 0usize;
for chunk in icc_chunks.iter().take(usize::from(total)) {
let Some(bytes) = chunk else {
m.icc_invalid = true;
break;
};
let Some(next_len) = profile_len.checked_add(bytes.len()) else {
m.icc_invalid = true;
break;
};
profile_len = next_len;
}
let maximum_len = usize::from(total).checked_mul(65_519);
if maximum_len.is_none_or(|maximum| profile_len > maximum) {
m.icc_invalid = true;
}
if !m.icc_invalid {
let mut profile = Vec::with_capacity(profile_len);
for bytes in icc_chunks.iter().take(usize::from(total)).flatten() {
profile.extend_from_slice(bytes);
}
m.icc = Some(profile);
}
}
Some(m)
}
@@ -201,8 +249,10 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback:
};
// A raw container may carry the colour declaration of an otherwise untagged preview.
// Apply it before linearization/resizing; existing JPEG metadata always wins.
let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
let hint = fallback
.filter(|_| m.icc.is_none() && !m.icc_invalid && m.exif.is_none())
.map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
let meta = Meta { icc: m.icc, icc_invalid: m.icc_invalid, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
finish(F, raw, meta, (m.width, m.height), opts)
}
@@ -450,6 +500,110 @@ mod tests {
assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err());
}
fn app2_icc(sequence: u8, total: u8, data: &[u8]) -> Vec<u8> {
let mut payload = b"ICC_PROFILE\0".to_vec();
payload.extend_from_slice(&[sequence, total]);
payload.extend_from_slice(data);
let mut segment = vec![0xff, 0xe2];
segment.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
segment.extend_from_slice(&payload);
segment
}
fn jpeg_with_icc_chunks(chunks: &[Vec<u8>]) -> Vec<u8> {
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
let pixels = [180, 60, 40, 180, 60, 40, 180, 60, 40, 180, 60, 40];
let base = encode_jpeg(&EncodeImage::new(2, 2, 3, Samples::U8(&pixels)), 100, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let mut jpeg = base.get(..2).unwrap_or_default().to_vec();
for chunk in chunks {
jpeg.extend_from_slice(chunk);
}
jpeg.extend_from_slice(base.get(2..).unwrap_or_default());
jpeg
}
#[test]
fn jpeg_icc_accepts_reordered_chunks_and_preserves_pixels() {
use crate::{NamedSpace, SpaceOrigin, icc};
let profile = icc::write_named(NamedSpace::DisplayP3);
let (first, second) = profile.split_at(profile.len() / 2);
let single = jpeg_with_icc_chunks(&[app2_icc(1, 1, &profile)]);
let reordered = jpeg_with_icc_chunks(&[app2_icc(2, 2, second), app2_icc(1, 2, first)]);
let one = crate::decode(&single, DecodeOptions::default()).unwrap();
let two = crate::decode(&reordered, DecodeOptions::default()).unwrap();
assert_eq!(one.icc.as_deref(), Some(profile.as_slice()));
assert_eq!(two.icc.as_deref(), Some(profile.as_slice()));
assert!(!parse_markers(&reordered).unwrap().icc_invalid);
assert_eq!(one.space, two.space);
assert_eq!(two.space.origin, SpaceOrigin::IccMatrixTrc);
let (a, b) = (one.to_working(), two.to_working());
assert_eq!(a.data.len(), b.data.len());
for (left, right) in a.data.iter().zip(&b.data) {
assert!(left.iter().zip(right).all(|(x, y)| (x - y).abs() < 0.00001), "{left:?} != {right:?}");
}
}
#[test]
fn jpeg_icc_rejects_incoherent_chunks_and_uses_unsupported_fallback() {
use crate::{NamedSpace, SpaceOrigin, icc};
let profile = icc::write_named(NamedSpace::DisplayP3);
let (first, second) = profile.split_at(profile.len() / 2);
let base = jpeg_with_icc_chunks(&[]);
let reference = crate::decode(&base, DecodeOptions::default()).unwrap();
let cases = [
("missing", vec![app2_icc(1, 2, &profile)]),
("duplicate", vec![app2_icc(1, 2, first), app2_icc(1, 2, second)]),
("zero sequence", vec![app2_icc(0, 1, &profile)]),
("zero total", vec![app2_icc(1, 0, &profile)]),
("out of range", vec![app2_icc(2, 1, &profile)]),
("gap", vec![app2_icc(1, 3, first), app2_icc(3, 3, second)]),
("inconsistent totals", vec![app2_icc(1, 2, first), app2_icc(2, 3, second)]),
("short header", {
let mut segment = vec![0xff, 0xe2];
segment.extend_from_slice(&15u16.to_be_bytes());
segment.extend_from_slice(b"ICC_PROFILE\0\x01");
vec![segment]
}),
];
for (name, chunks) in cases {
let jpeg = jpeg_with_icc_chunks(&chunks);
let markers = parse_markers(&jpeg).unwrap();
let decoded = crate::decode(&jpeg, DecodeOptions::default()).unwrap();
assert_eq!(decoded.space.origin, SpaceOrigin::IccUnsupported, "{name}: invalid profile must not be applied");
assert!(markers.icc.is_none(), "{name}: invalid ICC must not be assembled");
assert!(markers.icc_invalid, "{name}: invalid ICC presence must be retained");
assert_eq!(decoded.icc, None, "{name}: invalid ICC bytes must not escape decode");
assert_eq!(decoded.space.origin, SpaceOrigin::IccUnsupported, "{name}");
assert_eq!(decoded.space.named, Some(NamedSpace::Srgb), "{name}");
assert_eq!(decoded.to_working(), reference.to_working(), "{name}: fallback pixels");
}
}
#[test]
fn jpeg_icc_invalid_sequence_prevents_raw_colour_hint() {
use crate::{NamedSpace, SpaceOrigin, icc};
let profile = icc::write_named(NamedSpace::DisplayP3);
let jpeg = jpeg_with_icc_chunks(&[app2_icc(1, 2, &profile)]);
let decoded = crate::decode_jpeg_with_fallback(&jpeg, DecodeOptions::default(), NamedSpace::AdobeRgb).unwrap();
assert_eq!(decoded.space.origin, SpaceOrigin::IccUnsupported);
assert_eq!(decoded.space.named, Some(NamedSpace::Srgb));
}
#[test]
fn jpeg_icc_rejects_truncated_app2_segment() {
use crate::{NamedSpace, icc};
let profile = icc::write_named(NamedSpace::DisplayP3);
let mut segment = app2_icc(1, 1, &profile);
let declared = u16::from_be_bytes([segment[2], segment[3]]).saturating_add(1);
segment[2..4].copy_from_slice(&declared.to_be_bytes());
let mut jpeg = vec![0xff, 0xd8];
jpeg.extend_from_slice(&segment);
let markers = parse_markers(&jpeg).unwrap();
assert!(markers.icc.is_none());
assert!(markers.icc_invalid);
}
#[test]
fn scaled_request_covers() {
assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171));
+3
View File
@@ -20,6 +20,9 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr
35 mm equivalent, flash, exposure bias, program, metering, white balance), orientation, GPS, dimensions and the
user fields (artist, copyright, title, caption, keywords, hierarchical keywords, rating −1…5, colour label).
JPEG ICC data is exposed only when its APP2 chunks form a complete, consistent, uniquely numbered sequence;
malformed or truncated ICC sequences leave unrelated readable metadata available and produce no profile.
## XMP namespaces
Written: `dc`, `xmp`, `photoshop`, `exif`, `exifEX`, `tiff`, `lr` (`hierarchicalSubject`) and ours,
+124 -7
View File
@@ -90,7 +90,9 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded {
if !b.starts_with(&[0xff, 0xd8]) {
return out;
}
let mut icc_parts: Vec<(u8, Vec<u8>)> = Vec::new();
let mut icc_parts: [Option<&[u8]>; 255] = [None; 255];
let mut icc_total = None;
let mut icc_invalid = false;
let mut ext: Vec<(u32, Vec<u8>)> = Vec::new();
let mut i = 2usize;
while i + 4 <= b.len() {
@@ -113,8 +115,11 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded {
if len < 2 {
break;
}
let end = (i + 2 + len).min(b.len());
let payload = &b[i + 4..end];
let Some(start) = i.checked_add(4) else { break };
let Some(declared_end) = i.checked_add(2).and_then(|at| at.checked_add(len)) else { break };
let complete = declared_end <= b.len();
let end = declared_end.min(b.len());
let Some(payload) = b.get(start..end) else { break };
match marker {
0xe1 if payload.starts_with(b"Exif\0") && out.exif.is_none() => out.exif = Some(payload.get(6..).unwrap_or(&[]).to_vec()),
0xe1 if payload.starts_with(XMP_SIG) && out.xmp.is_none() => {
@@ -128,15 +133,64 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded {
ext.push((off, p[40..].to_vec()));
}
}
0xe2 if payload.starts_with(b"ICC_PROFILE\0") && payload.len() >= 14 => icc_parts.push((payload[12], payload[14..].to_vec())),
0xe2 if payload.starts_with(b"ICC_PROFILE\0") => {
if !complete || payload.len() < 14 {
icc_invalid = true;
} else if let Some(header) = payload.get(12..14) {
let (Some(&sequence), Some(&total)) = (header.first(), header.get(1)) else {
icc_invalid = true;
i = end;
continue;
};
if sequence == 0 || total == 0 || sequence > total || icc_total.is_some_and(|expected| expected != total) {
icc_invalid = true;
} else {
icc_total = Some(total);
if let Some(slot) = icc_parts.get_mut(usize::from(sequence - 1)) {
if slot.is_some() {
icc_invalid = true;
} else if let Some(data) = payload.get(14..) {
*slot = Some(data);
} else {
icc_invalid = true;
}
} else {
icc_invalid = true;
}
}
} else {
icc_invalid = true;
}
}
0xed if payload.starts_with(b"Photoshop 3.0\0") && out.iptc.is_none() => out.iptc = photoshop_iptc(&payload[14..]),
_ => {}
}
i = end;
}
if !icc_parts.is_empty() {
icc_parts.sort_by_key(|p| p.0);
out.icc = Some(icc_parts.into_iter().flat_map(|p| p.1).collect());
if let Some(total) = icc_total {
let mut profile_len = 0usize;
for chunk in icc_parts.iter().take(usize::from(total)) {
let Some(bytes) = chunk else {
icc_invalid = true;
break;
};
let Some(next_len) = profile_len.checked_add(bytes.len()) else {
icc_invalid = true;
break;
};
profile_len = next_len;
}
let maximum_len = usize::from(total).checked_mul(65_519);
if maximum_len.is_none_or(|maximum| profile_len > maximum) {
icc_invalid = true;
}
if !icc_invalid {
let mut profile = Vec::with_capacity(profile_len);
for bytes in icc_parts.iter().take(usize::from(total)).flatten() {
profile.extend_from_slice(bytes);
}
out.icc = Some(profile);
}
}
if !ext.is_empty() {
ext.sort_by_key(|p| p.0);
@@ -225,6 +279,26 @@ mod tests {
v
}
fn icc_seg(sequence: u8, total: u8, data: &[u8]) -> Vec<u8> {
let mut payload = b"ICC_PROFILE\0".to_vec();
payload.extend_from_slice(&[sequence, total]);
payload.extend_from_slice(data);
seg(0xe2, &payload)
}
fn jpeg_with_icc(parts: &[Vec<u8>]) -> Vec<u8> {
let mut jpeg = vec![0xff, 0xd8];
let packet = crate::write_xmp(&crate::Metadata { title: Some("retained".into()), ..Default::default() }, None);
let mut xmp = XMP_SIG.to_vec();
xmp.extend_from_slice(packet.as_bytes());
jpeg.extend_from_slice(&seg(0xe1, &xmp));
for part in parts {
jpeg.extend_from_slice(part);
}
jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]);
jpeg
}
pub(crate) fn sample_jpeg() -> Vec<u8> {
let mut j = vec![0xff, 0xd8];
j.extend(seg(0xe0, b"JFIF\0\x01\x02\0\0\x01\0\x01\0\0"));
@@ -281,6 +355,49 @@ mod tests {
assert_eq!(jpeg_segments(&j).xmp_extended.as_deref(), Some("helloworld"));
}
#[test]
fn jpeg_icc_accepts_reordered_chunks() {
let parts = [icc_seg(2, 2, b"world"), icc_seg(1, 2, b"hello")];
let embedded = jpeg_segments(&jpeg_with_icc(&parts));
assert_eq!(embedded.icc.as_deref(), Some(&b"helloworld"[..]));
}
#[test]
fn jpeg_icc_rejects_incomplete_or_incoherent_chunks() {
let cases = [
("missing", vec![icc_seg(1, 2, b"helloworld")]),
("duplicate", vec![icc_seg(1, 2, b"hello"), icc_seg(1, 2, b"world")]),
("zero sequence", vec![icc_seg(0, 1, b"helloworld")]),
("zero total", vec![icc_seg(1, 0, b"helloworld")]),
("out of range", vec![icc_seg(2, 1, b"helloworld")]),
("gap", vec![icc_seg(1, 3, b"hello"), icc_seg(3, 3, b"world")]),
("inconsistent totals", vec![icc_seg(1, 2, b"hello"), icc_seg(2, 3, b"world")]),
("short header", vec![seg(0xe2, b"ICC_PROFILE\0\x01")]),
];
for (name, parts) in cases {
let embedded = jpeg_segments(&jpeg_with_icc(&parts));
assert!(embedded.icc.is_none(), "{name} ICC chunks must be rejected");
assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("retained")), "{name}: unrelated XMP must remain readable");
}
}
#[test]
fn jpeg_icc_rejects_truncated_segment_and_keeps_other_metadata() {
let mut truncated = icc_seg(1, 1, b"profile");
let declared = u16::from_be_bytes([truncated[2], truncated[3]]).saturating_add(1);
truncated[2..4].copy_from_slice(&declared.to_be_bytes());
let mut jpeg = vec![0xff, 0xd8];
let xmp = crate::write_xmp(&crate::Metadata { title: Some("kept".into()), ..Default::default() }, None);
let mut packet = XMP_SIG.to_vec();
packet.extend_from_slice(xmp.as_bytes());
jpeg.extend_from_slice(&seg(0xe1, &packet));
jpeg.extend_from_slice(&truncated);
let embedded = jpeg_segments(&jpeg);
assert!(embedded.icc.is_none());
assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("kept")));
}
#[test]
fn png_and_webp() {
let exif = sample_exif(ByteOrder::Little);
+2 -1
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** patch (JPEG ICC APP2 sequence validation; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF)
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -84,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | patch | JPEG ICC APP2 chunks are validated as a complete, uniquely numbered sequence before colour conversion; ranked gaps and readiness counts unchanged |
| 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged |
| 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) |
| 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged |
File diff suppressed because one or more lines are too long
+7
View File
@@ -1,5 +1,12 @@
# What's new in LightCraft
## 2026-10-25
### JPEG ICC chunks are validated before colour conversion
- JPEG ICC profiles are assembled only when every chunk is present exactly once, the sequence numbers and
declared totals agree, and every APP2 segment is complete. Reordered valid chunks still work. An invalid
sequence uses the existing unsupported-profile sRGB fallback, including when the JPEG is a raw preview.
## 2026-10-24
### Relative-date filters and smart albums follow the clock