Palette PNGs open in Indexed Color mode with their colour table (part of #4804) (#4817)

This commit is contained in:
Marco Arnulfo authored and GitHub committed 2026-10-13 12:33:39 -04:00
1 parent efb67fc826
commit e4f005d3dc
6 files changed
+91 -9

No files matched your search

+2 -2
View File
@@ -63,8 +63,8 @@ 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.
Palette BMP and PNG pixels decode as RGB(A) in `Image`; `bmp_palette` and `png_palette` expose the
embedded table (and a PNG's transparent entry) so `photocraft-io` can open them in Indexed Color mode.
## Documented asymmetries and limitations
+58
View File
@@ -96,6 +96,37 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
/// The colour table of a palette PNG (`PLTE`) and the one entry its `tRNS` makes transparent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PngPalette {
pub colors: Vec<[u8; 3]>,
pub transparent: Option<u8>,
}
/// The colour table of a palette PNG (colour type 3), read from the headers without decoding
/// pixels. `None` for other colour types, and for a `tRNS` with partial alpha or several
/// transparent entries: an Indexed Color document holds one transparent entry.
pub fn palette(bytes: &[u8]) -> Result<Option<PngPalette>, CodecError> {
let mut decoder = png::Decoder::new_with_limits(Cursor::new(bytes), png::Limits::default());
decoder.set_ignore_text_chunk(true);
let reader = decoder.read_info().map_err(map_png_err)?;
let info = reader.info();
if info.color_type != png::ColorType::Indexed {
return Ok(None);
}
let Some(plte) = info.palette.as_deref() else { return Ok(None) };
let colors = plte.as_chunks::<3>().0.to_vec();
let mut transparent = None;
for (i, &a) in info.trns.as_deref().unwrap_or_default().iter().enumerate() {
match a {
255 => {}
0 if transparent.is_none() && i < colors.len() => transparent = u8::try_from(i).ok(),
_ => return Ok(None),
}
}
Ok(Some(PngPalette { colors, transparent }))
}
fn map_png_err(e: png::DecodingError) -> CodecError {
match e {
png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()),
@@ -595,6 +626,33 @@ mod indexed_tests {
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
}
fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec<u8> {
let mut out = Vec::new();
let mut e = png::Encoder::new(&mut out, 2, 1);
e.set_color(color);
e.set_depth(png::BitDepth::Eight);
e.set_palette(vec![1, 2, 3, 4, 5, 6]);
if let Some(t) = trns {
e.set_trns(t.to_vec());
}
let data = if color == png::ColorType::Rgb { vec![0; 6] } else { vec![0, 1] };
e.write_header().unwrap().write_image_data(&data).unwrap();
out
}
#[test]
fn palette_reads_the_table_and_its_one_transparent_entry() {
let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap();
let colors = vec![[1, 2, 3], [4, 5, 6]];
assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) }));
assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None }));
// A truecolour PNG's suggested palette is not a colour table.
assert_eq!(palette(&png(png::ColorType::Rgb, None)).unwrap(), None);
// Partial alpha or two transparent entries don't fit one transparent index.
assert_eq!(palette(&png(png::ColorType::Indexed, Some(&[255, 128]))).unwrap(), None);
assert_eq!(palette(&png(png::ColorType::Indexed, Some(&[0, 0]))).unwrap(), None);
}
}
#[cfg(test)]
+1 -1
View File
@@ -27,7 +27,7 @@ pub mod web;
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette};
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind};
pub use crate::error::CodecError;
+9 -4
View File
@@ -20,14 +20,19 @@ 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 };
// A palette BMP or PNG opens in Indexed Color mode with its colour table, as in Photoshop
// (#3802, #4804).
let palette = match codecs::detect(bytes) {
Some(Format::Bmp) => codecs::bmp_palette(bytes).ok().flatten().map(|c| (c, None)),
Some(Format::Png) => codecs::png_palette(bytes).ok().flatten().map(|p| (p.colors, p.transparent)),
_ => None,
};
let mut r = image_to_document(name, &img)?;
if let Some(colors) = bmp_palette.filter(|c| !c.is_empty())
if let Some((colors, transparent)) = 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.color_table = Some(photocraft_doc::ColorTable { colors, transparent });
}
r.document.file_encoding = file_encoding(bytes);
if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba {
+18
View File
@@ -100,6 +100,24 @@ fn indexed_bmp_opens_with_its_color_table() {
assert!((px[2] - 50.0 / 255.0).abs() < 1e-6);
}
/// A palette PNG opens in Indexed Color mode with its whole colour table and its transparent
/// entry, as in Photoshop (#4804).
#[test]
fn indexed_png_opens_with_its_color_table() {
let pal = [[10, 20, 30], [200, 100, 50], [7, 8, 9], [0, 255, 0]];
let png = photocraft_codecs::encode_png_indexed(2, 2, &[1, 0, 2, 1], &pal, Some(2), None).unwrap();
let doc = import("indexed.png", &png).unwrap().document;
assert_eq!(doc.mode, ColorMode::Indexed);
assert_eq!(doc.depth, SampleType::U8);
let table = doc.color_table.as_ref().unwrap();
assert_eq!(table.colors, pal, "every entry, used or not");
assert_eq!(table.transparent, Some(2));
let s = doc.layers[0].surface().unwrap();
let px = s.rgba(0, 0);
assert!((px[0] - 200.0 / 255.0).abs() < 1e-6 && (px[3] - 1.0).abs() < 1e-6);
assert_eq!(s.rgba(0, 1)[3], 0.0, "the tRNS entry is transparent");
}
/// 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-12 · **Last updated:** 2026-10-12 · **Change:** minor (CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: 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
@@ -26,7 +26,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
| Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) |
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) |
| 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) |
| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) |
| 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; 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) |
@@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) |
| 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) |
| 2026-10-12 | minor | DDS textures open, read-only (#1327) |
| 2026-10-12 | minor | #2465 fixed: Photoshop CS2 placed layers (`PlLd` only, file in `lnkD`) open as editable smart objects and save their edits |