mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
6 files changed
+67
-2
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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` |
|
||||
Reference in new issue
Block a user