fix(io): preserve indexed BMP color tables (#3802) (#3869)

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 09:13:38 -04:00
1 parent 3f72158f0a
commit aa4d4859b4
6 files changed
+67 -2

No files matched your search

+3
View File
@@ -61,6 +61,9 @@ the encode plan, and `fidelity_warnings` reports the conversion when it loses in
`Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays
the same.
Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded table so
`photocraft-io` can open them in Indexed Color mode.
## Documented asymmetries and limitations
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
+5
View File
@@ -32,6 +32,11 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError {
}
}
pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?;
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
+5
View File
@@ -98,6 +98,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
finish_decode(img, o, opts)
}
/// Returns the embedded colour table for an indexed BMP, if it has one.
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
via_image::bmp_palette(bytes)
}
/// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the
/// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or
/// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens.
+8
View File
@@ -20,7 +20,15 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
return import_tiff_page(name, bytes, None);
}
let img = codecs::decode(bytes)?;
// A palette BMP opens in Indexed Color mode with its colour table, as in Photoshop (#3802).
let bmp_palette = if codecs::detect(bytes) == Some(Format::Bmp) { codecs::bmp_palette(bytes).ok().flatten() } else { None };
let mut r = image_to_document(name, &img)?;
if let Some(colors) = bmp_palette.filter(|c| !c.is_empty())
&& r.document.mode == ColorMode::Rgb
{
r.document.mode = ColorMode::Indexed;
r.document.color_table = Some(photocraft_doc::ColorTable { colors, transparent: None });
}
r.document.file_encoding = file_encoding(bytes);
if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba {
tga_alpha_to_channel(&mut r.document)?;
+43
View File
@@ -54,6 +54,49 @@ codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0);
codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0);
codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0);
fn indexed_bmp_8() -> Vec<u8> {
const FILE_HEADER_SIZE: usize = 14;
const INFO_HEADER_SIZE: usize = 40;
const PALETTE_ENTRY_SIZE: usize = 4;
const PALETTE: [[u8; 3]; 2] = [[10, 20, 30], [200, 100, 50]];
const PIXEL_ROW: [u8; 4] = [0, 1, 0, 0];
let pixel_offset = FILE_HEADER_SIZE + INFO_HEADER_SIZE + PALETTE.len() * PALETTE_ENTRY_SIZE;
let file_size = pixel_offset + PIXEL_ROW.len();
let mut bytes = Vec::with_capacity(file_size);
bytes.extend_from_slice(b"BM");
bytes.extend_from_slice(&(file_size as u32).to_le_bytes());
bytes.extend_from_slice(&[0; 4]);
bytes.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
bytes.extend_from_slice(&(INFO_HEADER_SIZE as u32).to_le_bytes());
bytes.extend_from_slice(&2i32.to_le_bytes());
bytes.extend_from_slice(&1i32.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&8u16.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&(PIXEL_ROW.len() as u32).to_le_bytes());
bytes.extend_from_slice(&0i32.to_le_bytes());
bytes.extend_from_slice(&0i32.to_le_bytes());
bytes.extend_from_slice(&(PALETTE.len() as u32).to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
for [r, g, b] in PALETTE {
bytes.extend_from_slice(&[b, g, r, 0]);
}
bytes.extend_from_slice(&PIXEL_ROW);
bytes
}
#[test]
fn indexed_bmp_opens_with_its_color_table() {
let doc = import("indexed.bmp", &indexed_bmp_8()).unwrap().document;
assert_eq!(doc.mode, ColorMode::Indexed);
assert_eq!(doc.depth, SampleType::U8);
assert_eq!(doc.color_table.as_ref().unwrap().colors.get(..2), Some(&[[10, 20, 30], [200, 100, 50]][..]));
let px = doc.layers[0].surface().unwrap().rgba(1, 0);
assert!((px[0] - 200.0 / 255.0).abs() < 1e-6);
assert!((px[1] - 100.0 / 255.0).abs() < 1e-6);
assert!((px[2] - 50.0 / 255.0).abs() < 1e-6);
}
/// FITS header cards travel as the document's text metadata, so an astro frame keeps its
/// OBJECT, EXPTIME and the rest through an edit.
#[test]
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Save keeps a JPEG's quality and a WebP's lossy/lossless encoding) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
@@ -28,7 +28,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) |
| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) |
| GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done |
| BMP | R/W | yes | yes | 8-bit |
| BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode |
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) |
| AVIF | R/W | no (#463) | optional feature, off by default | |
| JPEG XL | R/W | inside DNG only | no | |
@@ -140,6 +140,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) |
| 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) |
| 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning |
| 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` |