Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	crates/io/src/psd_export.rs
#	crates/io/src/tiff_layers.rs
This commit is contained in:
David Mann
2026-10-26 14:32:07 +01:00
496 changed files with 37550 additions and 6466 deletions
+38 -7
View File
@@ -39,6 +39,14 @@ pub struct GlobalLayerMask {
}
impl GlobalLayerMask {
/// The mask Photoshop writes for a layered file that has no global mask of its own:
/// an RGB red overlay at 50% opacity, kind 128 (use the per-layer setting), plus the
/// filler byte. A zero-length `LMsk` block in a TIFF makes Photoshop give up on the
/// layer data (it reads this 14-byte record from the block).
pub fn photoshop_default() -> Self {
GlobalLayerMask { data: vec![0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] }
}
fn be16(&self, at: usize) -> Option<u16> {
self.data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]]))
}
@@ -490,12 +498,12 @@ impl PsdFile {
}
/// `true` if the merged image carries a transparency channel: its first extra channel, when
/// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`,
/// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive
/// layer count (psd-tools transparentbg.psd). Photoshop also names the merged transparency
/// "Transparency" and lists it first among the alpha channel names, and some of its 16-bit
/// PSBs carry no other mark (#4654): when the layers sit in a global `Lr16`/`Lr32` block, that
/// name with one name per extra channel marks it too.
/// the layer count was stored negative or a "Saving Merged Transparency" global block (see
/// [`TaggedBlock::is_merged_transparency_marker`]) is present. Photoshop marks 32-bit documents
/// with `Mt32` and a positive layer count (psd-tools transparentbg.psd). Photoshop also names
/// the merged transparency "Transparency" and lists it first among the alpha channel names,
/// and some of its 16-bit PSBs carry no other mark (#4654): when the layers sit in a global
/// `Lr16`/`Lr32` block, that name with one name per extra channel marks it too.
pub fn merged_has_alpha(&self) -> bool {
let Some(color) = self.header.color_mode.color_channels() else {
return false;
@@ -503,7 +511,7 @@ impl PsdFile {
if self.header.channels <= color {
return false;
}
let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32"));
let marked = self.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker);
match &self.layer_info {
Some(li) => {
let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. });
@@ -804,4 +812,27 @@ mod write_tests {
}
}
}
/// A positive layer count plus a merged-transparency marker still makes the first extra
/// channel the transparency, for the specification's keys and for the `MTrn` Photoshop
/// writes in a layered TIFF.
#[test]
fn merged_transparency_markers_mark_the_first_extra_channel() {
let mut file = testgen::small(Version::Psd, Compression::Raw);
if let Some(li) = &mut file.layer_info {
li.merged_alpha = false;
}
assert!(!file.merged_has_alpha());
for key in [*b"Mtrn", *b"MTrn", *b"Mt16", *b"Mt32"] {
let mut marked = file.clone();
marked.global_blocks.push(TaggedBlock::new(key, Vec::new()));
assert!(marked.merged_has_alpha(), "{}", String::from_utf8_lossy(&key));
}
// Other keys, and the marker's letters in another case, do not.
for key in [*b"MTRN", *b"mtrn", *b"Lr16"] {
let mut other = file.clone();
other.global_blocks.push(TaggedBlock::new(key, Vec::new()));
assert!(!other.merged_has_alpha(), "{}", String::from_utf8_lossy(&key));
}
}
}
+37 -2
View File
@@ -391,6 +391,10 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec<u8>) -> Result<()> {
out.extend_from_slice(&(p.height as u16).to_be_bytes());
out.extend_from_slice(&(p.width as u16).to_be_bytes());
let units: Vec<u16> = p.name.encode_utf16().chain(std::iter::once(0)).collect();
// The reader refuses longer names (see `read_pattern`).
if units.len() > 65_536 {
return Err(PsdError::LimitExceeded("pattern name length"));
}
out.extend_from_slice(&(units.len() as u32).to_be_bytes());
for u in units {
out.extend_from_slice(&u.to_be_bytes());
@@ -533,6 +537,9 @@ pub fn block_key(depth: u16) -> [u8; 4] {
}
}
/// The most patterns a `.pat` file may hold: more are refused on read and on write.
pub const MAX_PAT_PATTERNS: usize = 100_000;
/// Parses a `.pat` pattern file.
pub fn parse_pat_file(data: &[u8]) -> Result<Vec<PsdPattern>> {
let mut r = Rd { b: data, p: 0 };
@@ -541,10 +548,13 @@ pub fn parse_pat_file(data: &[u8]) -> Result<Vec<PsdPattern>> {
return Err(PsdError::InvalidSignature { expected: "8BPT", found: [sig[0], sig[1], sig[2], sig[3]] });
}
let _version = r.u16()?;
let count = r.u32()?;
let count = r.u32()? as usize;
if count > MAX_PAT_PATTERNS {
return Err(PsdError::LimitExceeded("pattern count"));
}
let mut out = Vec::new();
let mut budget = PatternBudget::default();
for _ in 0..count.min(100_000) {
for _ in 0..count {
out.push(read_pattern(&mut r, &mut budget)?);
}
Ok(out)
@@ -552,6 +562,9 @@ pub fn parse_pat_file(data: &[u8]) -> Result<Vec<PsdPattern>> {
/// Writes a `.pat` pattern file.
pub fn write_pat_file(patterns: &[PsdPattern]) -> Result<Vec<u8>> {
if patterns.len() > MAX_PAT_PATTERNS {
return Err(PsdError::LimitExceeded("pattern count"));
}
let mut out = b"8BPT".to_vec();
out.extend_from_slice(&1u16.to_be_bytes());
out.extend_from_slice(&(patterns.len() as u32).to_be_bytes());
@@ -582,6 +595,28 @@ mod tests {
}
}
/// #9318 review: a name the reader refuses (more than 65,536 UTF-16 units with its NUL) is
/// refused on write, so a saved library can't become a file that loads nothing.
#[test]
fn a_name_the_reader_refuses_is_refused_on_write() {
let long = PsdPattern { name: "a".repeat(65_536), ..pat(8, false) };
assert!(matches!(write_pat_file(&[long]), Err(PsdError::LimitExceeded(_))));
let longest = PsdPattern { name: "a".repeat(65_535), ..pat(8, false) };
assert_eq!(parse_pat_file(&write_pat_file(std::slice::from_ref(&longest)).unwrap()).unwrap(), [longest]);
}
/// #9318 review: more patterns than a `.pat` holds are refused on write, and a file claiming
/// more is refused on read rather than silently cut to the first 100,000.
#[test]
fn more_patterns_than_a_pat_file_holds_are_refused() {
let many = vec![pat(8, false); MAX_PAT_PATTERNS + 1];
assert!(matches!(write_pat_file(&many), Err(PsdError::LimitExceeded(_))));
let mut header = b"8BPT".to_vec();
header.extend_from_slice(&1u16.to_be_bytes());
header.extend_from_slice(&(MAX_PAT_PATTERNS as u32 + 1).to_be_bytes());
assert!(matches!(parse_pat_file(&header), Err(PsdError::LimitExceeded(_))));
}
#[test]
fn block_and_pat_file_round_trip_at_every_depth() {
for depth in [8, 16, 32] {
+8
View File
@@ -155,6 +155,14 @@ impl TaggedBlock {
Self::is_foreign_order_signature(&self.signature)
}
/// `true` for a "Saving Merged Transparency" marker: the merged image's first extra channel
/// is its transparency. The specification's keys are `Mtrn`, `Mt16` and `Mt32`; Photoshop
/// (27.11) writes `MTrn`, with a positive layer count, in a layered TIFF saved with Save
/// Transparency next to spot and alpha channels.
pub fn is_merged_transparency_marker(&self) -> bool {
matches!(&self.key, b"Mtrn" | b"MTrn" | b"Mt16" | b"Mt32")
}
/// Key as a string (lossy).
pub fn key_str(&self) -> String {
String::from_utf8_lossy(&self.key).into_owned()
+130 -4
View File
@@ -1163,16 +1163,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()?;
@@ -1837,6 +1837,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
View File
@@ -232,6 +232,7 @@ fn global_mask_accessors() {
assert_eq!(gm.opacity(), Some(50));
assert_eq!(gm.kind(), Some(128));
assert_eq!(GlobalLayerMask::default().kind(), None);
assert_eq!(GlobalLayerMask::photoshop_default().data, gm.data);
}
#[test]