mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
fix(tiff): preserve PSD byte order in layered TIFF text descriptors (#9098)
This commit is contained in:
@@ -5,9 +5,14 @@ use photocraft_doc::text::{CharStyle, TextRun};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer};
|
||||
use photocraft_geom::Affine;
|
||||
use photocraft_io::{ExportOptions, PsdExportOptions, document_to_psd_with, export, import, psd_to_document};
|
||||
use photocraft_psd::descriptor::{Value, VersionedDescriptor};
|
||||
|
||||
fn document() -> Document {
|
||||
let mut doc = Document::new("Synthetic type", Size::new(640, 400), ColorMode::Rgb, SampleType::U8);
|
||||
document_in(ColorMode::Rgb, SampleType::U8)
|
||||
}
|
||||
|
||||
fn document_in(mode: ColorMode, depth: SampleType) -> Document {
|
||||
let mut doc = Document::new("Synthetic type", Size::new(640, 400), mode, depth);
|
||||
doc.resolution_dpi = 300.0;
|
||||
let mut t = TextLayer {
|
||||
text: "Synthetic".into(),
|
||||
@@ -46,6 +51,49 @@ fn photoshop_mixed_order_tiff_import_keeps_editable_geometry_and_pixels() {
|
||||
assert_eq!(rerendered.content_bounds(), after.cache.as_ref().unwrap().content_bounds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_tiff_export_keeps_tysh_text_descriptor_big_endian() {
|
||||
for (mode, depth) in [
|
||||
(ColorMode::Rgb, SampleType::U8),
|
||||
(ColorMode::Rgb, SampleType::U16),
|
||||
(ColorMode::Rgb, SampleType::F32),
|
||||
(ColorMode::Grayscale, SampleType::U8),
|
||||
(ColorMode::Grayscale, SampleType::U16),
|
||||
(ColorMode::Grayscale, SampleType::F32),
|
||||
(ColorMode::Cmyk, SampleType::U8),
|
||||
(ColorMode::Cmyk, SampleType::U16),
|
||||
] {
|
||||
let doc = document_in(mode, depth);
|
||||
let (psd, _) = document_to_psd_with(&doc, &PsdExportOptions::default());
|
||||
let canonical = &psd.layers()[0].block(b"TySh").unwrap().data;
|
||||
let encoded = export(&doc, "test.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap();
|
||||
let (_, layers) = photocraft_codecs::tiff_photoshop_tags(&encoded.bytes);
|
||||
let layers = layers.expect("layered TIFF tag 37724");
|
||||
let block_at = layers.windows(8).position(|window| window == b"MIB8hSyT").expect("little-endian TySh block");
|
||||
let length_at = block_at + 8;
|
||||
let length_bytes: [u8; 4] = layers.get(length_at..length_at + 4).expect("TySh length").try_into().expect("four-byte length");
|
||||
let length = usize::try_from(u32::from_le_bytes(length_bytes)).expect("test block length fits usize");
|
||||
let data_at = length_at + 4;
|
||||
let payload = layers.get(data_at..data_at + length).expect("TySh payload");
|
||||
let (_, descriptor_len) = VersionedDescriptor::parse_prefix(canonical.get(52..).unwrap()).unwrap();
|
||||
let text_section_len = 2 + descriptor_len;
|
||||
assert_eq!(payload.get(50..52), Some(&50u16.to_be_bytes()[..]), "{mode:?} {depth:?}");
|
||||
assert_eq!(payload.get(52..56), Some(&16u32.to_be_bytes()[..]), "{mode:?} {depth:?}");
|
||||
assert_eq!(payload.get(50..50 + text_section_len), canonical.get(50..50 + text_section_len), "{mode:?} {depth:?}");
|
||||
let descriptor = VersionedDescriptor::parse_prefix(payload.get(52..).unwrap()).unwrap().0;
|
||||
assert!(matches!(descriptor.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == doc.layers[0].name));
|
||||
|
||||
let opened = import("photoshop.tif", &encoded.bytes).unwrap();
|
||||
assert!(!opened.warnings.iter().any(|warning| warning.contains("TySh")), "{:?}", opened.warnings);
|
||||
let LayerContent::Text(before) = &doc.layers[0].content else { panic!("source type") };
|
||||
let LayerContent::Text(after) = &opened.document.layers[0].content else { panic!("imported type") };
|
||||
assert_eq!(after.transform, before.transform, "{mode:?} {depth:?}");
|
||||
assert_eq!(after.size_pt, before.size_pt, "{mode:?} {depth:?}");
|
||||
assert_eq!(after.text, before.text, "{mode:?} {depth:?}");
|
||||
assert_eq!(after.cache.as_ref().unwrap().content_bounds(), before.cache.as_ref().unwrap().content_bounds(), "{mode:?} {depth:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_or_unsafe_type_keeps_cached_pixels_without_editable_geometry() {
|
||||
let doc = document();
|
||||
|
||||
@@ -348,7 +348,11 @@ fn a_fifo_in_place_of_a_module_file_does_not_block_loading() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("evil.wasm");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
rustix::fs::mknodat(rustix::fs::CWD, &path, rustix::fs::FileType::Fifo, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR, 0).unwrap();
|
||||
// `rustix::fs::mknodat` is unavailable on Apple targets. `mkfifo` is the
|
||||
// POSIX utility for creating this special file and keeps this regression
|
||||
// test available on macOS as well as other Unix platforms.
|
||||
let status = std::process::Command::new("mkfifo").arg(&path).status().unwrap();
|
||||
assert!(status.success(), "mkfifo failed with {status}");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let p = path.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
+130
-4
@@ -1162,16 +1162,16 @@ pub mod transcode {
|
||||
fn type_layer(&mut self) -> Result<()> {
|
||||
self.u16()?;
|
||||
self.f64s(6)?;
|
||||
self.type_descriptor(50)?;
|
||||
self.type_descriptor(1)?;
|
||||
self.type_descriptor(50, ByteOrder::Big)?;
|
||||
self.type_descriptor(1, self.to)?;
|
||||
self.i32s(4)
|
||||
}
|
||||
|
||||
fn type_descriptor(&mut self, version: u16) -> Result<()> {
|
||||
fn type_descriptor(&mut self, version: u16, to: ByteOrder) -> Result<()> {
|
||||
let [hi, lo] = version.to_be_bytes();
|
||||
let from = if self.from.is_little() && self.r.peek_rest().starts_with(&[hi, lo, 0, 0, 0, 16]) { ByteOrder::Big } else { self.from };
|
||||
let data = self.r.peek_rest();
|
||||
let mut descriptor = Tx::new(data, self.version, from, self.to);
|
||||
let mut descriptor = Tx::new(data, self.version, from, to);
|
||||
descriptor.depth = self.depth;
|
||||
descriptor.u16()?;
|
||||
descriptor.versioned_descriptor()?;
|
||||
@@ -1836,6 +1836,132 @@ mod tests {
|
||||
block_rt(*b"lfx2", data);
|
||||
}
|
||||
|
||||
fn type_layer_data() -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
data.put_u16(1);
|
||||
for value in [1.5, 0.0, -0.25, 1.75, 12.5, -3.25] {
|
||||
data.put_f64(value);
|
||||
}
|
||||
data.put_u16(50);
|
||||
let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Synthetic Ω 🦀")));
|
||||
data.extend(VersionedDescriptor::new(text).to_bytes());
|
||||
data.put_u16(1);
|
||||
data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes());
|
||||
for value in [11, -22, 333, 4_444] {
|
||||
data.put_i32(value);
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn tagged_payload<'a>(bytes: &'a [u8], signature_and_key: &[u8; 8], little: bool) -> &'a [u8] {
|
||||
let block_at = bytes.windows(8).position(|window| window == signature_and_key).expect("tagged block");
|
||||
let length_at = block_at + 8;
|
||||
let length_bytes: [u8; 4] = bytes.get(length_at..length_at + 4).expect("tagged block length").try_into().expect("four-byte length");
|
||||
let length = if little { u32::from_le_bytes(length_bytes) } else { u32::from_be_bytes(length_bytes) };
|
||||
let data_at = length_at + 4;
|
||||
let data_end = data_at + usize::try_from(length).expect("test block length fits usize");
|
||||
bytes.get(data_at..data_end).expect("tagged block payload")
|
||||
}
|
||||
|
||||
fn legacy_little_endian_type_block() -> Vec<u8> {
|
||||
let mut tysh = Vec::new();
|
||||
tysh.extend_from_slice(&1u16.to_le_bytes());
|
||||
for value in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25] {
|
||||
tysh.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
tysh.extend_from_slice(&50u16.to_le_bytes());
|
||||
// Independently encode a little-endian `VersionedDescriptor` with a single TEXT value.
|
||||
tysh.extend_from_slice(&16u32.to_le_bytes());
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character class id
|
||||
tysh.extend_from_slice(b"rLxT"); // little-endian four-character class id
|
||||
tysh.extend_from_slice(&1u32.to_le_bytes()); // one item
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character key
|
||||
tysh.extend_from_slice(b" txT"); // little-endian four-character key
|
||||
tysh.extend_from_slice(b"TXET"); // little-endian OSType
|
||||
let mut units: Vec<u16> = "Synthetic Ω 🦀".encode_utf16().collect();
|
||||
units.push(0); // `new_nul`, matching the canonical fixture
|
||||
tysh.extend_from_slice(&u32::try_from(units.len()).expect("short test string").to_le_bytes());
|
||||
for unit in units {
|
||||
tysh.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
tysh.extend_from_slice(&1u16.to_le_bytes());
|
||||
// Empty warp descriptor, also encoded independently in little-endian order.
|
||||
tysh.extend_from_slice(&16u32.to_le_bytes());
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name
|
||||
tysh.extend_from_slice(&4u32.to_le_bytes()); // `warp` is a four-byte string id
|
||||
tysh.extend_from_slice(b"warp");
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // no items
|
||||
for value in [11i32, -22, 333, 4_444] {
|
||||
tysh.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
let mut image_source = super::SIGNATURE.to_vec();
|
||||
image_source.extend_from_slice(b"MIB8hSyT");
|
||||
image_source.extend_from_slice(&u32::try_from(tysh.len()).expect("short test block").to_le_bytes());
|
||||
image_source.extend_from_slice(&tysh);
|
||||
let padding = (4 - tysh.len() % 4) % 4;
|
||||
image_source.resize(image_source.len() + padding, 0);
|
||||
image_source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_block_write_keeps_text_descriptor_in_psd_order() {
|
||||
let canonical = type_layer_data();
|
||||
let source = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"TySh", canonical.clone())], ..Default::default() };
|
||||
|
||||
let (little, warnings) = source.to_bytes(ByteOrder::Little).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let payload = tagged_payload(&little, b"MIB8hSyT", true);
|
||||
assert_eq!(payload.get(..2), Some(&[1, 0][..]), "TySh header stays in TIFF order");
|
||||
for (index, expected) in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25].into_iter().enumerate() {
|
||||
let start = 2 + index * 8;
|
||||
let bytes: [u8; 8] = payload.get(start..start + 8).unwrap().try_into().unwrap();
|
||||
assert_eq!(f64::from_le_bytes(bytes), expected, "matrix element {index}");
|
||||
}
|
||||
|
||||
let (text_descriptor, descriptor_len) = VersionedDescriptor::parse_prefix(canonical.get(52..).unwrap()).unwrap();
|
||||
let text_section_len = 2 + descriptor_len;
|
||||
assert_eq!(payload.get(50..52), Some(&50u16.to_be_bytes()[..]));
|
||||
assert_eq!(payload.get(52..56), Some(&16u32.to_be_bytes()[..]));
|
||||
assert_eq!(payload.get(50..50 + text_section_len), canonical.get(50..50 + text_section_len));
|
||||
assert!(matches!(text_descriptor.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀"));
|
||||
let text_descriptor_from_output = VersionedDescriptor::parse_prefix(payload.get(52..).unwrap()).unwrap().0;
|
||||
assert!(matches!(text_descriptor_from_output.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀"));
|
||||
|
||||
let warp_at = 50 + text_section_len;
|
||||
assert_eq!(payload.get(warp_at..warp_at + 2), Some(&1u16.to_le_bytes()[..]));
|
||||
let (_, warp_len) = VersionedDescriptor::parse_prefix(canonical.get(warp_at + 2..).unwrap()).unwrap();
|
||||
let warp_descriptor_at = warp_at + 2;
|
||||
assert_eq!(payload.get(warp_descriptor_at..warp_descriptor_at + 4), Some(&16u32.to_le_bytes()[..]));
|
||||
assert_eq!(payload.get(warp_descriptor_at + 4..warp_descriptor_at + 8), Some(&0u32.to_le_bytes()[..]));
|
||||
assert_eq!(payload.get(warp_descriptor_at + 8..warp_descriptor_at + 12), Some(&4u32.to_le_bytes()[..]));
|
||||
assert_eq!(payload.get(warp_descriptor_at + 12..warp_descriptor_at + 16), Some(b"warp".as_slice()));
|
||||
assert_eq!(payload.get(warp_descriptor_at + 16..warp_descriptor_at + 20), Some(&0u32.to_le_bytes()[..]));
|
||||
let bounds_at = warp_descriptor_at + warp_len;
|
||||
for (index, expected) in [11i32, -22, 333, 4_444].into_iter().enumerate() {
|
||||
let start = bounds_at + index * 4;
|
||||
assert_eq!(payload.get(start..start + 4), Some(&expected.to_le_bytes()[..]), "bound {index}");
|
||||
}
|
||||
|
||||
let (big, warnings) = source.to_bytes(ByteOrder::Big).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(tagged_payload(&big, b"8BIMTySh", false), canonical);
|
||||
|
||||
let (reopened, warnings) = ImageSourceData::from_bytes(&little).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(reopened.global_blocks[0].data, canonical);
|
||||
assert_eq!(reopened.to_bytes(ByteOrder::Big).unwrap().0, big);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_little_endian_type_descriptor_still_imports() {
|
||||
let input = legacy_little_endian_type_block();
|
||||
let (reopened, warnings) = ImageSourceData::from_bytes(&input).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(reopened.global_blocks[0].data, type_layer_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_block_layout() {
|
||||
let mut data = Vec::new();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 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-24 · **Last updated:** 2026-10-24 · **Change:** minor (layered TIFF now writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds retain container order (#9098); Photoshop mixed-order TIFF type geometry remains supported on read (#4758/#4771); legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 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
|
||||
@@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
|
||||
| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2 with their warp: the bend presets, a custom mesh and, since #4112, Cylinder; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (53 mapped, among them Invert, the Distort filters but Shear, Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending, each layer's XMP packet (`layer.xmpMetadata`). Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) |
|
||||
| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 |
|
||||
| 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, including Photoshop mixed-order type blocks (#4758); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7: extra alpha channels not written) |
|
||||
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order TySh text descriptors on read (#4758/#4771) and write (#9098); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (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); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview |
|
||||
| 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); Save keeps a stereo PNG's `sTER` pairing (#6063) |
|
||||
| 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 |
|
||||
@@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. |
|
||||
| 2026-10-22 | minor | 32-bit Hue/Saturation as Photoshop does it: the raised saturation isn't clipped (saturated colours leave 0..1), the chroma is scaled around the lightness by Photoshop's saturation factor (measured at every whole percent), so colours at or above lightness 1 keep their hue, and Colorize builds its colour with the saturation times 1 − \|Lightness\| (8 and 16 bits unchanged): photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) |
|
||||
| 2026-10-19 | minor | JPEG Options on File › Save, Save As and Save a Copy (FILE-215-19 partial: no Matte, progressive Scans or size preview) |
|
||||
| 2026-10-18 | minor | Scripted TIFF saves take `tiffCompression` (none, LZW, ZIP), and the CLI `--tiff-compression` (#5301) |
|
||||
|
||||
Reference in New Issue
Block a user