mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 03:36:48 -04:00
This commit is contained in:
1 parent
efb67fc826
commit
e4f005d3dc
6 files changed
+91
-9
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user