mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
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:
+38
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user