diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index b025a7a5f..8829f656d 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -111,10 +111,46 @@ struct Ctx<'a> { pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: bool, rate: FrameRate, duration: Tick) -> PsdImport { let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder); let mut cx = Ctx { psd, path, retain, folder, rate, duration, items: vec![], warnings: vec![] }; - let comp = build_comp(proj, &mut cx, name, &psd.tree(), None); + cx.warnings.extend(psd.warnings.iter().cloned()); + let (tree, flattened) = psd.tree_report(); + if flattened > 0 { + cx.warnings.push(format!("{flattened} group(s) nested deeper than {} levels were flattened into their parent", effectcraft_psd::MAX_GROUP_DEPTH)); + } + let comp = + if psd.layers.is_empty() && !psd.warnings.is_empty() { flattened_comp(proj, &mut cx, name) } else { build_comp(proj, &mut cx, name, &tree, None) }; PsdImport { comp, folder, items: cx.items, warnings: cx.warnings } } +/// The layers could not be read: the merged image as the composition's only layer. +fn flattened_comp(proj: &mut Project, cx: &mut Ctx, name: &str) -> ItemId { + let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(Arc::new(new_comp(cx)))); + cx.items.push(cid); + let mut comp = new_comp(cx); + let file = std::path::Path::new(cx.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let f = Footage { + path: cx.path.to_string(), + kind: FootageKind::Still, + width: cx.psd.width, + height: cx.psd.height, + pixel_aspect: 1.0, + frame_rate: cx.rate, + has_video: true, + alpha: if cx.psd.merged_alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, + loop_count: 1, + codec: "PSD".into(), + layer: None, + ..Default::default() + }; + let id = proj.add_item(&file, Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); + cx.items.push(id); + let l = build::layer(proj, &comp, name, LayerSource::Footage { item: id }, (cx.psd.width, cx.psd.height), None); + comp.layers.push(l); + if let Some(item) = proj.item_mut(cid) { + item.kind = ItemKind::Comp(Arc::new(comp)); + } + cid +} + fn new_comp(cx: &Ctx) -> Comp { // A PSB can be 300,000 px a side; a composition is at most 30,000 (#788). let size = |v: u32| effectcraft_project::limits::size(v.into()); @@ -202,7 +238,8 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz let bb = so.placed_bounds(cw as f64, ch as f64); let area = ((bb[2] - bb[0]) * (bb[3] - bb[1])).max(1.0); let k = ((cw as f64 * ch as f64) / area).sqrt().clamp(1.0, 4.0); - let (w, h) = so.placed_size(cw as f64, ch as f64, k); + // At most 4× the document across and 2^24 pixels, however far the file moves a corner. + let (k, (w, h)) = so.bake_scale(cw as f64, ch as f64, k, (cx.psd.width, cx.psd.height)); (w, h, k, bb) } else { (cw, ch, 1.0, [0.0; 4]) @@ -333,13 +370,21 @@ fn layer_origin(pl: &effectcraft_psd::Layer, retain: bool, layer: &Layer) -> [f6 [0.0, 0.0] } +/// Sets a scalar property; a non-finite value (from corrupt transforms) keeps the default. fn set_scalar(l: &mut Layer, path: &str, v: f64) { + if !v.is_finite() { + return; + } if let Some(p) = l.props.prop_mut(path) { p.value = Value::Scalar(v); } } +/// Sets a 2D property; a non-finite component (from corrupt transforms) keeps the default. fn set_vec2(l: &mut Layer, path: &str, v: [f64; 2]) { + if !v.iter().all(|c| c.is_finite()) { + return; + } if let Some(p) = l.props.prop_mut(path) { p.value = match p.value { Value::Vec3(o) => Value::Vec3([v[0], v[1], o[2]]), @@ -361,8 +406,10 @@ fn text_doc(t: &effectcraft_psd::TextInfo) -> TextDoc { d.set_text(&t.text); let [xx, xy, yx, yy, _, _] = t.transform; let scale = (xx * yy - xy * yx).abs().sqrt().max(1e-6); - if let Some(s) = t.style.size { - d.size = s * scale; + // A transform too large to multiply (inf) falls back to 1×; products below stay finite. + let scale = if scale.is_finite() { scale } else { 1.0 }; + if let Some(s) = t.style.size.map(|s| s * scale).filter(|s| s.is_finite() && *s > 0.0) { + d.size = s; } if let Some(f) = &t.style.font { let (fam, style) = font_names(f); @@ -381,19 +428,20 @@ fn text_doc(t: &effectcraft_psd::TextInfo) -> TextDoc { Some(6) => Justify::JustifyAll, _ => Justify::Left, }; - if let Some(tr) = t.style.tracking { + if let Some(tr) = t.style.tracking.filter(|v| v.is_finite()) { d.tracking = tr; } - if let Some(l) = t.style.leading { - d.leading = Some(l * scale); - } + d.leading = t.style.leading.map(|l| l * scale).filter(|l| l.is_finite()); d.faux_bold = t.style.faux_bold; d.faux_italic = t.style.faux_italic; if t.style.box_text && let Some([l, top, r, b]) = t.bounds { - d.box_size = Some([(r - l) * scale, (b - top) * scale]); - d.box_pos = [l * scale, top * scale]; + let (size, pos) = ([(r - l) * scale, (b - top) * scale], [l * scale, top * scale]); + if size.iter().chain(&pos).all(|v| v.is_finite()) { + d.box_size = Some(size); + d.box_pos = pos; + } } d } @@ -716,4 +764,32 @@ mod tests { assert_eq!(desc_blend(Some("linearDodge")), Some(BlendMode::LinearDodge)); assert_eq!(font_names("Inter-Bold"), ("Inter".into(), "Bold".into())); } + + #[test] + fn non_finite_text_values_never_reach_the_layer() { + let style = effectcraft_psd::engine_data::TextStyle { + size: Some(f64::NAN), + tracking: Some(f64::INFINITY), + leading: Some(f64::NEG_INFINITY), + ..Default::default() + }; + let t = effectcraft_psd::TextInfo { + text: "x".into(), + transform: [f64::INFINITY, 0.0, 0.0, 1.0, f64::NAN, 1.0], + style, + bounds: Some([0.0, 0.0, f64::INFINITY, 1.0]), + }; + let d = text_doc(&t); + assert!(d.size.is_finite() && d.tracking.is_finite()); + assert!(d.leading.is_none_or(f64::is_finite)); + assert!(d.box_size.is_none_or(|b| b.iter().all(|v| v.is_finite()))); + let mut proj = Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_24, Tick::ZERO); + let l = text_layer(&mut proj, &comp, "t", &t); + match l.props.prop("transform/position").map(|p| &p.value) { + Some(Value::Vec2(v)) => assert!(v.iter().all(|c| c.is_finite())), + Some(Value::Vec3(v)) => assert!(v.iter().all(|c| c.is_finite())), + other => panic!("unexpected position {other:?}"), + } + } } diff --git a/crates/engine/src/tests_vector_import.rs b/crates/engine/src/tests_vector_import.rs index e33a38b08..63513b910 100644 --- a/crates/engine/src/tests_vector_import.rs +++ b/crates/engine/src/tests_vector_import.rs @@ -283,3 +283,57 @@ fn paste_svg_rejects_text_that_is_not_usable_svg() { assert!(s.execute_checked("edit.pasteSvg", json!({})).is_err()); assert_eq!(s.active_comp().unwrap().layers.len(), 1, "nothing pasted"); } + +#[test] +fn psd_with_thousands_of_nested_groups_imports_on_a_small_stack() { + let mut d = WDoc::new(8, 8); + let n = 5000; + d.layers = (0..n).map(|_| WLayer::group_end()).collect(); + for i in 0..n { + d.layers.push(WLayer::solid(&format!("L{i}"), Rect::new(0, 0, 2, 2), [1.0; 4])); + d.layers.push(WLayer::group(&format!("G{i}"), true, *b"norm")); + } + let path = tmp("nested.psd"); + std::fs::write(&path, effectcraft_psd::write::write(&d)).unwrap(); + let h = std::thread::Builder::new() + .stack_size(2 << 20) + .spawn(move || { + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + let warned = r["errors"].as_array().is_some_and(|e| e.len() == 1 && e[0].as_str().is_some_and(|m| m.contains("flattened"))); + // 64 nested group comps plus the root comp. + let comps = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count(); + (warned, comps) + }) + .unwrap(); + let (warned, comps) = h.join().unwrap(); + assert_eq!(comps, 65); + assert!(warned); +} + +#[test] +fn psd_with_damaged_layer_section_imports_the_flattened_image() { + let mut d = WDoc::new(30, 20); + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 30, 20), [0.0, 0.0, 1.0, 1.0]), + WLayer::solid("Box", Rect::new(2, 2, 8, 8), [1.0, 0.0, 0.0, 1.0]).with_blend(b"mul "), + ]; + let mut bytes = write(&d); + let at = bytes.windows(8).position(|w| w == b"8BIMmul ").unwrap(); + bytes[at] = b'X'; + let path = tmp("damaged.psd"); + std::fs::write(&path, bytes).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + // Photoshop import warnings are reported with the import's `errors` (as for adjustment layers). + let warnings = r["errors"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].as_str().unwrap().contains("Layers could not be read"), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + let c = s.project.comp(cid).unwrap().clone(); + assert_eq!((c.width, c.height), (30, 20)); + assert_eq!(c.layers.len(), 1); + let LayerSource::Footage { item } = c.layers[0].source else { panic!("footage") }; + let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; + assert!(f.layer.is_none(), "the merged image, not a Photoshop layer"); +} diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index b28c3479e..ca711b7ae 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -174,17 +174,17 @@ fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result { let n = r.u32()?; DValue::ObjectArray(n, read_descriptor_at(r, depth + 1)?) } - b"doub" => DValue::Double(r.f64()?), + b"doub" => DValue::Double(r.f64_finite()?), b"UntF" => { let unit = String::from_utf8_lossy(&r.tag()?).into_owned(); - DValue::UnitFloat(unit, r.f64()?) + DValue::UnitFloat(unit, r.f64_finite()?) } b"UnFl" => { let unit = String::from_utf8_lossy(&r.tag()?).into_owned(); let n = r.u32()? as usize; let mut v = Vec::with_capacity(n.min(1024)); for _ in 0..n { - v.push(r.f64()?); + v.push(r.f64_finite()?); } DValue::UnitFloats(unit, v) } @@ -248,7 +248,11 @@ fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result { // ---------------------------------------------------------------- writing -fn put_u32(out: &mut Vec, v: u32) { +pub(crate) fn put_u16(out: &mut Vec, v: u16) { + out.extend_from_slice(&v.to_be_bytes()); +} + +pub(crate) fn put_u32(out: &mut Vec, v: u32) { out.extend_from_slice(&v.to_be_bytes()); } @@ -261,14 +265,21 @@ fn put_id(out: &mut Vec, id: &str) { out.extend_from_slice(id.as_bytes()); } -pub(crate) fn put_unicode(out: &mut Vec, s: &str) { - let v: Vec = s.encode_utf16().chain(std::iter::once(0)).collect(); +/// A length-prefixed UTF-16BE string (the length counts code units, including the NUL when +/// `terminated`). +pub(crate) fn put_utf16(out: &mut Vec, s: &str, terminated: bool) { + let v: Vec = s.encode_utf16().chain(terminated.then_some(0)).collect(); put_u32(out, v.len() as u32); for c in v { - out.extend_from_slice(&c.to_be_bytes()); + put_u16(out, c); } } +/// A descriptor Unicode string: NUL-terminated. +pub(crate) fn put_unicode(out: &mut Vec, s: &str) { + put_utf16(out, s, true); +} + /// Serialise a descriptor (without the version field). pub fn write_descriptor(out: &mut Vec, d: &Descriptor) { put_unicode(out, &d.class_name); diff --git a/crates/psd/src/engine_data.rs b/crates/psd/src/engine_data.rs index b0a177d0a..20fa1676d 100644 --- a/crates/psd/src/engine_data.rs +++ b/crates/psd/src/engine_data.rs @@ -2,6 +2,9 @@ //! tree of dictionaries (`<< /Key value >>`), arrays (`[ ]`), numbers, names, booleans and //! strings (`(þÿ` + UTF-16BE with backslash escapes `)`). Read best-effort. +/// Bytes that end a bare token or a `/Name` (besides whitespace). +const DELIMITERS: &[u8] = b"[]<>/("; + #[derive(Clone, Debug, PartialEq)] pub enum EValue { Dict(Vec<(String, EValue)>), @@ -121,7 +124,7 @@ impl P<'_> { b'(' => Some(EValue::Str(self.string())), _ => { let start = self.i; - while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !b"[]<>/(".contains(&self.b[self.i]) { + while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !DELIMITERS.contains(&self.b[self.i]) { self.i += 1; } let tok = std::str::from_utf8(&self.b[start..self.i]).unwrap_or(""); @@ -132,7 +135,11 @@ impl P<'_> { Some(match tok { "true" => EValue::Bool(true), "false" => EValue::Bool(false), - t => t.parse::().map(EValue::Num).unwrap_or_else(|_| EValue::Name(t.to_string())), + // "nan", "inf" and overflowing literals parse as f64 but are not numbers here. + t => match t.parse::() { + Ok(n) if n.is_finite() => EValue::Num(n), + _ => EValue::Name(t.to_string()), + }, }) } } @@ -140,7 +147,7 @@ impl P<'_> { fn name(&mut self) -> String { self.i += 1; // '/' let start = self.i; - while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !b"[]<>/(".contains(&self.b[self.i]) { + while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !DELIMITERS.contains(&self.b[self.i]) { self.i += 1; } String::from_utf8_lossy(&self.b[start..self.i]).into_owned() @@ -213,6 +220,11 @@ pub struct TextStyle { pub box_text: bool, } +/// Sane upper bound for a font size in points (Photoshop's own limit is 1296 pt). +const MAX_FONT_SIZE: f64 = 10_000.0; +/// Sane bound for tracking (1/1000 em) and leading (points). +const MAX_SPACING: f64 = 100_000.0; + pub fn text_style(ed: &EValue) -> TextStyle { let engine = ed.get("EngineDict").unwrap_or(ed); let resources = ed.get("ResourceDict").or_else(|| ed.get("DocumentResources")); @@ -233,11 +245,11 @@ pub fn text_style(ed: &EValue) -> TextStyle { let box_text = engine.path("Rendered/Shapes/Children/0/ShapeType").and_then(EValue::num) == Some(1.0); TextStyle { font, - size: attr("FontSize").and_then(EValue::num), + size: attr("FontSize").and_then(EValue::num).filter(|s| *s > 0.0).map(|s| s.min(MAX_FONT_SIZE)), color, justification, - tracking: attr("Tracking").and_then(EValue::num), - leading: if auto_leading { None } else { attr("Leading").and_then(EValue::num) }, + tracking: attr("Tracking").and_then(EValue::num).map(|t| t.clamp(-MAX_SPACING, MAX_SPACING)), + leading: if auto_leading { None } else { attr("Leading").and_then(EValue::num).map(|l| l.clamp(-MAX_SPACING, MAX_SPACING)) }, faux_bold: attr("FauxBold") == Some(&EValue::Bool(true)), faux_italic: attr("FauxItalic") == Some(&EValue::Bool(true)), box_text, @@ -285,4 +297,20 @@ mod tests { let _ = parse(b"]]]>>>> /x"); let _ = parse(&[0xFF; 64]); } + + #[test] + fn non_finite_and_out_of_range_numbers_are_not_numbers() { + let b = b"<< /FontSize nan /Tracking inf /Leading -inf /Other 1e999 >>"; + let v = parse(b); + assert_eq!(v.get("FontSize").and_then(EValue::num), None); + assert_eq!(v.get("Other").and_then(EValue::num), None); + let ed = parse(b"<< /EngineDict << /StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /FontSize nan /Tracking inf /Leading -inf /AutoLeading false >> >> >> ] >> >> >>"); + let st = text_style(&ed); + assert_eq!((st.size, st.tracking, st.leading), (None, None, None)); + let ed = parse(b"<< /EngineDict << /StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /FontSize 1e300 /Tracking -1e300 /AutoLeading false /Leading 1e300 >> >> >> ] >> >> >>"); + let st = text_style(&ed); + assert_eq!(st.size, Some(MAX_FONT_SIZE)); + assert_eq!(st.tracking, Some(-MAX_SPACING)); + assert_eq!(st.leading, Some(MAX_SPACING)); + } } diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 1c0fd3d4d..5ae32126a 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -15,6 +15,7 @@ pub mod placed; mod read; pub mod write; +use std::borrow::Cow; use std::sync::Arc; pub use descriptor::{DValue, Descriptor}; @@ -56,6 +57,101 @@ fn decode_pixels(w: usize, h: usize) -> Result { } } +fn too_large(w: usize, h: usize) -> Error { + Error::Unsupported(format!("{w}×{h} pixels is too large to decode")) +} + +/// What one decode (the composite, or one layer) may hold in memory at once: 4 GiB, 1 GiB where +/// pointers are 32 bits wide (WebAssembly). [`MAX_DECODE_PIXELS`] bounds one image; this bounds +/// the sum of the buffers a decode keeps alive together. +#[cfg(target_pointer_width = "32")] +const DECODE_BUDGET: u64 = 1 << 30; +#[cfg(not(target_pointer_width = "32"))] +const DECODE_BUDGET: u64 = 4 << 30; + +/// The bytes the buffers of one decode hold right now, against a limit. A decode charges a buffer +/// before it allocates it (and releases it when it drops it), so a file that would need more +/// than the limit fails with an error instead of an out-of-memory abort. +struct Budget { + limit: u64, + live: u64, +} + +impl Budget { + fn new(limit: u64) -> Budget { + Budget { limit, live: 0 } + } + + fn charge(&mut self, bytes: u64) -> Result<()> { + self.check(bytes)?; + self.live += bytes; + Ok(()) + } + + /// Whether `bytes` more would still fit, without taking them. A decode checks its whole + /// expected peak first, so one that cannot fit fails before it has allocated anything. + fn check(&self, bytes: u64) -> Result<()> { + match self.live.checked_add(bytes) { + Some(total) if total <= self.limit => Ok(()), + _ => { + const MIB: u64 = 1 << 20; + Err(Error::Unsupported(format!( + "decoding needs {} MiB more with {} MiB in use, over the {} MiB memory budget", + bytes / MIB, + self.live / MIB, + self.limit / MIB + ))) + } + } + } + + fn release(&mut self, bytes: u64) { + self.live = self.live.saturating_sub(bytes); + } + + /// An empty vector with room for `n` items, charged. + fn vec(&mut self, n: usize) -> Result> { + self.charge((n as u64).saturating_mul(std::mem::size_of::() as u64))?; + let mut v = Vec::new(); + v.try_reserve_exact(n).map_err(|_| Error::Unsupported(format!("out of memory for {n} items")))?; + Ok(v) + } + + /// `n` copies of `value`, charged. + fn filled(&mut self, n: usize, value: T) -> Result> { + let mut v = self.vec::(n)?; + v.resize(n, value); + Ok(v) + } +} + +/// Inflate a zlib stream to at most `limit` bytes. +fn inflate(src: &[u8], limit: usize) -> Result> { + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(src, limit).map_err(|e| Error::Invalid(format!("zip: {e:?}"))) +} + +/// Give back what a channel buffer from `Psd::channel_bytes` / `Psd::decode_plane` was charged: +/// a borrowed one (uncompressed data, or a plane of an inflated stream charged as a whole) was not. +fn release_bytes(budget: &mut Budget, raw: Cow<'_, [u8]>) { + if let Cow::Owned(v) = raw { + budget.release(v.len() as u64); + } +} + +/// A 32-bit float sample made safe to use: NaN reads as 0, +inf as white (1.0, like the integer +/// depths' maximum), -inf as 0. +fn finite_sample(v: f32) -> f32 { + if v.is_nan() { + 0.0 + } else if v == f32::INFINITY { + 1.0 + } else if v == f32::NEG_INFINITY { + 0.0 + } else { + v + } +} + /// Whether `bytes` start like a Photoshop document. pub fn is_psd(bytes: &[u8]) -> bool { bytes.len() >= 6 && &bytes[..4] == b"8BPS" && matches!(u16::from_be_bytes([bytes[4], bytes[5]]), 1 | 2) @@ -120,7 +216,7 @@ pub struct Rect { impl Rect { pub fn new(left: i32, top: i32, width: i32, height: i32) -> Rect { - Rect { top, left, bottom: top + height, right: left + width } + Rect { top, left, bottom: top.saturating_add(height), right: left.saturating_add(width) } } pub fn width(&self) -> u32 { (i64::from(self.right) - i64::from(self.left)).clamp(0, i64::from(u32::MAX)) as u32 @@ -306,6 +402,10 @@ impl Layer { } } +/// How deep groups nest in [`Psd::tree`]; deeper ones are flattened (a hostile file can nest +/// thousands, and walking or dropping that tree would overflow the stack). +pub const MAX_GROUP_DEPTH: usize = 64; + /// The layer tree, top of the stack first. #[derive(Clone, Debug, PartialEq)] pub enum Node { @@ -339,6 +439,9 @@ pub struct Psd { pub palette: Vec, /// Files embedded for smart objects. pub linked: Vec, + /// Repairs made while reading: damage that was skipped instead of failing the document + /// (for example an unreadable layer section, in which case only the merged image is kept). + pub warnings: Vec, data: Arc<[u8]>, merged_offset: usize, } @@ -387,6 +490,7 @@ impl Psd { merged_alpha: false, palette, linked: vec![], + warnings: vec![], data: data.clone(), merged_offset: 0, }; @@ -395,8 +499,17 @@ impl Psd { if lm_len > 0 { let li_len = r.length(psb)?; let li_end = r.pos.checked_add(li_len).filter(|&e| e <= lm_end).ok_or(Error::Truncated)?; - if li_len > 0 { - psd.layer_info(&data, r.pos, li_end)?; + if li_len > 0 + && let Err(e) = psd.layer_info(&data, r.pos, li_end) + { + // A damaged layer section must not discard the document: keep the header and the + // merged image, drop the layers and say so. Without a merged image there is + // nothing to fall back to, so the original error stands. + if lm_end.checked_add(2).is_none_or(|n| n > data.len()) { + return Err(e); + } + psd.layers.clear(); + psd.warnings.push(format!("Layers could not be read ({e}); only the flattened image is available")); } r.pos = li_end; if r.pos + 4 <= lm_end { @@ -422,6 +535,10 @@ impl Psd { r.pos = end; } } + if !psd.layers.is_empty() { + // A 16/32-bit layer block recovered the layers. + psd.warnings.clear(); + } psd.merged_offset = lm_end; Ok(psd) } @@ -597,6 +714,10 @@ impl Psd { for v in &mut t { *v = r.f64()?; } + // A transform with NaN/inf is unusable: fall back to the identity. + if t.iter().any(|v| !v.is_finite()) { + t = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + } let _text_ver = r.u16()?; let _desc_ver = r.u32()?; let d = descriptor::read_descriptor(&mut r)?; @@ -651,9 +772,13 @@ impl Psd { } b"expA" => { let _ver = r.u16()?; - let exposure = r.f32()? as f64; - let offset = r.f32()? as f64; - let gamma = r.f32()? as f64; + let mut finite = |default: f64| -> Result { + let v = f64::from(r.f32()?); + Ok(if v.is_finite() { v } else { default }) + }; + let exposure = finite(0.0)?; + let offset = finite(0.0)?; + let gamma = finite(1.0)?; l.adjustment = Some(Adjustment::Exposure { exposure, offset, gamma }); } b"curv" | b"blnc" | b"post" | b"thrs" | b"selc" | b"mixr" | b"phfl" | b"vibA" | b"blwh" | b"grdm" | b"clrL" if l.adjustment.is_none() => { @@ -665,27 +790,12 @@ impl Psd { let _dver = r.u32()?; let d = descriptor::read_descriptor(&mut r)?; let uuid = d.text("Idnt").unwrap_or_default().to_string(); - let nums = |k: &str| -> Option> { - match d.get(k)? { - DValue::List(l) => Some( - l.iter() - .filter_map(|v| match v { - DValue::Double(x) | DValue::UnitFloat(_, x) => Some(*x), - DValue::Integer(i) => Some(*i as f64), - _ => None, - }) - .collect(), - ), - DValue::UnitFloats(_, v) => Some(v.clone()), - _ => None, - } - }; - let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]); + let t = placed::eight(&d, "Trnf").unwrap_or([0.0; 8]); // The non-affine transform when present (perspective), else the transform. - let t = nums("nonAffineTransform").filter(|t| t.len() == 8).unwrap_or(t); + let t = placed::eight(&d, "nonAffineTransform").unwrap_or(t); let size = d.obj("Sz ").and_then(|s| Some([s.num("Wdth")?, s.num("Hght")?])); let warp = d.obj("warp").and_then(placed::Warp::read); - l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size, warp }); + l.smart_object = Some(SmartObject { uuid, quad: placed::quad_from(t), size, warp }); } b"PlLd" if l.smart_object.is_none() => { let _id = r.tag()?; @@ -698,9 +808,9 @@ impl Psd { let _ty = r.u32()?; let mut t = [0.0; 8]; for v in &mut t { - *v = r.f64()?; + *v = r.f64_finite()?; } - l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None, warp: None }); + l.smart_object = Some(SmartObject { uuid, quad: placed::quad_from(t), size: None, warp: None }); } b"vmsk" | b"vsms" => { let _ver = r.u32()?; @@ -742,13 +852,39 @@ impl Psd { Ok(vm) } - /// The layer tree, top of the stack first. + /// The layer tree, top of the stack first. Groups nest at most [`MAX_GROUP_DEPTH`] deep (see + /// [`Psd::tree_report`]). pub fn tree(&self) -> Vec { + self.tree_report().0 + } + + /// The layer tree plus the number of groups nested deeper than [`MAX_GROUP_DEPTH`]. Those + /// are flattened into their deepest allowed ancestor: their children stay, in order, and + /// the group record itself becomes a plain [`Node::Layer`], so no layer is lost and the + /// tree (and its `Drop`, `Clone` and `PartialEq`) cannot recurse deeper than the limit. + pub fn tree_report(&self) -> (Vec, usize) { let mut stack: Vec> = vec![vec![]]; + // Dividers seen while `MAX_GROUP_DEPTH` groups were already open. + let mut flattened_open = 0usize; + let mut flattened = 0usize; for l in &self.layers { match l.section { - Section::Divider => stack.push(vec![]), + Section::Divider => { + if stack.len() > MAX_GROUP_DEPTH { + flattened_open += 1; + } else { + stack.push(vec![]); + } + } Section::OpenFolder | Section::ClosedFolder => { + if flattened_open > 0 { + flattened_open -= 1; + flattened += 1; + if let Some(top) = stack.last_mut() { + top.push(Node::Layer(l.index)); + } + continue; + } let children = if stack.len() > 1 { stack.pop().unwrap_or_default() } else { vec![] }; if let Some(top) = stack.last_mut() { top.push(Node::Group { layer: l.index, children }); @@ -767,6 +903,7 @@ impl Psd { top.extend(c); } } + // Depth is at most `MAX_GROUP_DEPTH`, so recursion is bounded. fn rev(v: &mut Vec) { v.reverse(); for n in v { @@ -777,7 +914,7 @@ impl Psd { } let mut out = stack.pop().unwrap_or_default(); rev(&mut out); - out + (out, flattened) } fn bytes_per_sample(&self) -> usize { @@ -788,38 +925,119 @@ impl Psd { } } - /// Decode one channel of `w`×`h` samples at `offset` (compression field first). Values 0..1. - fn decode_channel(&self, offset: usize, len: usize, w: usize, h: usize) -> Result> { + /// The raw big-endian bytes of one channel of `w`×`h` samples at `offset` (compression field + /// first): `row_bytes(w) × h` of them with the compression and the prediction undone. + /// Uncompressed data is borrowed from the file, not copied; any other buffer is charged to + /// `budget` and the caller releases it (see [`release_bytes`]) once it is dropped. + fn channel_bytes(&self, offset: usize, len: usize, w: usize, h: usize, budget: &mut Budget) -> Result> { let end = offset.checked_add(len).filter(|&e| e <= self.data.len()).ok_or(Error::Truncated)?; - let mut r = Reader::at(&self.data[..end], offset); + let mut r = Reader::at(self.data.get(..end).ok_or(Error::Truncated)?, offset); let comp = r.u16()?; decode_pixels(w, h)?; + self.plane_bytes(comp, &mut r, w, h, budget) + } + + /// The raw bytes (`row_bytes(w)` × `h`, ZIP prediction undone) of one channel whose pixel data + /// follows `r`'s position (after the compression field). Uncompressed data is borrowed, not + /// copied; the other buffers are charged to `budget`. + fn plane_bytes<'a>(&self, comp: u16, r: &mut Reader<'a>, w: usize, h: usize, budget: &mut Budget) -> Result> { let rowb = self.row_bytes(w); - let raw = match comp { - 0 => r.bytes(rowb * h)?.to_vec(), + let size = rowb.checked_mul(h).ok_or_else(|| too_large(w, h))?; + Ok(match self.open_planes(comp, r, 1, 1, w, h, budget)? { + PlaneStream::Raw => Cow::Borrowed(r.bytes(size)?), + PlaneStream::Rle(counts) => Cow::Owned(Self::unpack_plane(&counts, 0, r, rowb, h, budget)?), + PlaneStream::Zip(mut v) => { + v.resize(size, 0); + self.zip_plane(comp, &mut v, 0, w, h)?; + Cow::Owned(v) + } + }) + } + + /// Prepare the pixel data at `r` (just after the compression field) of `total` planes of + /// `w`×`h` samples, of which the first `want` are decoded: the RLE row sizes of every plane, + /// or the inflated ZIP data, which holds all planes one after another (charged to `budget` + /// before it is inflated). + #[allow(clippy::too_many_arguments)] + fn open_planes(&self, comp: u16, r: &mut Reader, total: usize, want: usize, w: usize, h: usize, budget: &mut Budget) -> Result { + let size = self.row_bytes(w).checked_mul(h).ok_or_else(|| too_large(w, h))?; + Ok(match comp { + 0 => PlaneStream::Raw, 1 => { - let mut counts = Vec::with_capacity(h.min(r.remaining() / 2)); - for _ in 0..h { + let n = total.saturating_mul(h); + let mut counts = Vec::with_capacity(n.min(r.remaining() / 2)); + for _ in 0..n { counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); } - let mut out = Vec::with_capacity(rowb * h); - for c in counts { - unpack_bits(r.bytes(c)?, rowb, &mut out)?; - } - out + PlaneStream::Rle(counts) } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; - v.resize(rowb * h, 0); - if comp == 3 { - self.unpredict(&mut v, w, h); - } - v + // One stream holds every plane: what it may decode to is what they need. + let all = size.checked_mul(want).ok_or_else(|| too_large(w, h))?; + budget.charge(all as u64)?; + PlaneStream::Zip(inflate(rest, all)?) } c => return Err(Error::Unsupported(format!("compression {c}"))), - }; - Ok(self.samples(&raw, w, h)) + }) + } + + /// The raw bytes (`row_bytes(w)` × `h`, ZIP prediction undone) of plane `c`: raw and RLE + /// planes are read from `r` in order, raw ones borrowed from the file data, ZIP ones from the + /// inflated buffer (un-predicted in place; already charged by `open_planes`), RLE ones charged + /// to `budget`. + #[allow(clippy::too_many_arguments)] + fn decode_plane<'x, 'a: 'x>( + &self, + comp: u16, + planes: &'x mut PlaneStream, + r: &mut Reader<'a>, + c: usize, + w: usize, + h: usize, + budget: &mut Budget, + ) -> Result> { + let rowb = self.row_bytes(w); + let size = rowb.checked_mul(h).ok_or_else(|| too_large(w, h))?; + Ok(match planes { + PlaneStream::Raw => Cow::Borrowed(r.bytes(size)?), + PlaneStream::Rle(counts) => Cow::Owned(Self::unpack_plane(counts, c, r, rowb, h, budget)?), + PlaneStream::Zip(v) => Cow::Borrowed(self.zip_plane(comp, v, c, w, h)?), + }) + } + + /// Unpack the `h` RLE rows of plane `c` (row byte counts `counts`, plane after plane) into a + /// buffer charged to `budget`. + fn unpack_plane(counts: &[usize], c: usize, r: &mut Reader, rowb: usize, h: usize, budget: &mut Budget) -> Result> { + let mut out = budget.vec::(rowb.checked_mul(h).ok_or(Error::Truncated)?)?; + for row in 0..h { + let n = *counts.get(c * h + row).ok_or(Error::Truncated)?; + unpack_bits(r.bytes(n)?, rowb, &mut out)?; + } + Ok(out) + } + + /// Plane `c` of the inflated ZIP data of consecutive planes, un-predicted in place (`comp` 3). + fn zip_plane<'v>(&self, comp: u16, v: &'v mut [u8], c: usize, w: usize, h: usize) -> Result<&'v [u8]> { + let rowb = self.row_bytes(w); + let plane = v.get_mut(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?; + if comp == 3 { + self.unpredict(plane, w, h); + } + Ok(plane) + } + + /// One channel as `w * h` floats (0..1), charged to `budget` (the caller releases it). + fn channel_floats(&self, offset: usize, len: usize, w: usize, h: usize, budget: &mut Budget) -> Result> { + let raw = self.channel_bytes(offset, len, w, h, budget)?; + let mut out = budget.filled(decode_pixels(w, h)?, 0.0f32)?; + self.for_each_sample(&raw, w, h, |i, v| { + if let Some(o) = out.get_mut(i) { + *o = v; + } + }); + release_bytes(budget, raw); + Ok(out) } fn row_bytes(&self, w: usize) -> usize { @@ -839,12 +1057,16 @@ impl Psd { } } 32 => { + // One scratch row for the whole plane. + let mut planar = vec![0u8; w * 4]; + #[cfg(test)] + counters::bump(&counters::SCRATCH); for row in v.chunks_exact_mut(w * 4).take(h) { for i in 1..row.len() { row[i] = row[i].wrapping_add(row[i - 1]); } // Bytes are stored plane by plane (all first bytes, then all second bytes…). - let planar = row.to_vec(); + planar.copy_from_slice(row); for x in 0..w { for b in 0..4 { row[x * 4 + b] = planar[b * w + x]; @@ -862,29 +1084,31 @@ impl Psd { } } - fn samples(&self, raw: &[u8], w: usize, h: usize) -> Vec { + /// Hand every sample of `raw` (`w`×`h`, row-major, in order) to `f` as a float. Samples a short + /// `raw` does not have are not reported. + fn for_each_sample(&self, raw: &[u8], w: usize, h: usize, mut f: impl FnMut(usize, f32)) { + let n = w.saturating_mul(h); match self.depth { 1 => { let rowb = w.div_ceil(8); - let mut out = Vec::with_capacity(w * h); for y in 0..h { for x in 0..w { let b = raw.get(y * rowb + x / 8).copied().unwrap_or(0); // 1 = black. - out.push(if b & (0x80 >> (x % 8)) != 0 { 0.0 } else { 1.0 }); + f(y * w + x, if b & (0x80 >> (x % 8)) != 0 { 0.0 } else { 1.0 }); } } - out } - 16 => raw.as_chunks::<2>().0.iter().take(w * h).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), - 32 => raw.as_chunks::<4>().0.iter().take(w * h).map(|c| f32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(), - _ => raw.iter().take(w * h).map(|&b| b as f32 / 255.0).collect(), + 16 => raw.as_chunks::<2>().0.iter().take(n).enumerate().for_each(|(i, c)| f(i, u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0)), + // NaN reads as 0, +inf as white (1.0, like the integer depths' maximum), -inf as 0. + 32 => raw.as_chunks::<4>().0.iter().take(n).enumerate().for_each(|(i, c)| f(i, finite_sample(f32::from_be_bytes([c[0], c[1], c[2], c[3]])))), + _ => raw.iter().take(n).enumerate().for_each(|(i, &b)| f(i, b as f32 / 255.0)), } } - /// Convert colour channels (in the document's mode) to RGB. - fn to_rgb(&self, ch: &[Option>], i: usize) -> [f32; 3] { - let g = |k: usize| ch.get(k).and_then(|c| c.as_ref()).map_or(0.0, |c| c[i]); + /// Convert colour values (the document mode's channels, missing ones 0) to RGB. + fn rgb_from(&self, v: [f32; 4]) -> [f32; 3] { + let g = |k: usize| v.get(k).copied().unwrap_or(0.0); let rgb = match self.mode { ColorMode::Rgb => [g(0), g(1), g(2)], ColorMode::Cmyk => { @@ -902,55 +1126,69 @@ impl Psd { if self.depth == 32 && self.mode != ColorMode::Lab { rgb.map(srgb_encode) } else { rgb } } + /// Write merged-image channel `c` (raw bytes) into the result: the first three colour channels + /// and the alpha channel into their slot of a pixel, the fourth CMYK channel into `black`. + fn store_merged_channel(&self, c: usize, raw: &[u8], data: &mut [[f32; 4]], black: &mut Option>) { + let (w, h, cc) = (self.width as usize, self.height as usize, self.mode.color_channels()); + if c < cc.min(3) { + self.for_each_sample(raw, w, h, |i, v| { + if let Some(p) = data.get_mut(i) { + p[c] = v; + } + }); + } else if c == 3 && c < cc { + if let Some(k) = black { + self.for_each_sample(raw, w, h, |i, v| { + if let Some(o) = k.get_mut(i) { + *o = v; + } + }); + } + } else if c == cc && self.merged_alpha { + self.for_each_sample(raw, w, h, |i, v| { + if let Some(p) = data.get_mut(i) { + p[3] = v; + } + }); + } + } + /// The merged (flattened) image. pub fn composite(&self) -> Result { + self.composite_with_budget(DECODE_BUDGET) + } + + /// [`Self::composite`] with at most `limit` bytes live at once. The channels are written + /// straight into the result, so the peak is the result (16 bytes a pixel), the CMYK black + /// channel, and the one buffer a channel (or, for ZIP, all channels) decodes through. + pub(crate) fn composite_with_budget(&self, limit: u64) -> Result { + let mut budget = Budget::new(limit); let (w, h) = (self.width as usize, self.height as usize); - decode_pixels(w, h)?; + let n = decode_pixels(w, h)?; let mut r = Reader::at(&self.data, self.merged_offset); let comp = r.u16()?; let nch = self.channels as usize; let cc = self.mode.color_channels(); let want = (cc + usize::from(self.merged_alpha)).min(nch); let rowb = self.row_bytes(w); - let mut planes: Vec>> = vec![None; nch]; - match comp { - 0 => { - for p in planes.iter_mut().take(want) { - *p = Some(self.samples(r.bytes(rowb * h)?, w, h)); - } - } - 1 => { - let mut counts = Vec::with_capacity((nch * h).min(r.remaining() / 2)); - for _ in 0..nch * h { - counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); - } - for (c, p) in planes.iter_mut().enumerate().take(want) { - let mut out = Vec::with_capacity(rowb * h); - for row in 0..h { - unpack_bits(r.bytes(counts[c * h + row])?, rowb, &mut out)?; - } - *p = Some(self.samples(&out, w, h)); - } - } - 2 | 3 => { - let rest = r.bytes(r.remaining())?; - let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; - for (c, p) in planes.iter_mut().enumerate().take(want) { - let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); - if comp == 3 { - self.unpredict(&mut plane, w, h); - } - *p = Some(self.samples(&plane, w, h)); - } - } - c => return Err(Error::Unsupported(format!("compression {c}"))), + let size = rowb.checked_mul(h).ok_or_else(|| too_large(w, h))?; + // The peak: the result, the CMYK black channel and what the channels decode through. + let black_bytes = if self.mode == ColorMode::Cmyk && want > 3 { 4 * n as u64 } else { 0 }; + let transient = if matches!(comp, 2 | 3) { (size as u64).saturating_mul(want as u64) } else { size as u64 }; + budget.check((16 * n as u64).saturating_add(black_bytes).saturating_add(transient))?; + // Opaque black until a channel says otherwise. + let mut data = budget.filled(n, [0.0f32, 0.0, 0.0, 1.0])?; + let mut black = if self.mode == ColorMode::Cmyk && want > 3 { Some(budget.filled(n, 0.0f32)?) } else { None }; + let mut stream = self.open_planes(comp, &mut r, nch, want, w, h, &mut budget)?; + for c in 0..want { + let raw = self.decode_plane(comp, &mut stream, &mut r, c, w, h, &mut budget)?; + self.store_merged_channel(c, &raw, &mut data, &mut black); + release_bytes(&mut budget, raw); } - let alpha = if self.merged_alpha && nch > cc { planes[cc].take() } else { None }; - let mut data = Vec::with_capacity(w * h); - for i in 0..w * h { - let [r, g, b] = self.to_rgb(&planes, i); - let a = alpha.as_ref().map_or(1.0, |a| a[i]); - data.push([r, g, b, a]); + for (i, p) in data.iter_mut().enumerate() { + let k = black.as_ref().and_then(|k| k.get(i)).copied().unwrap_or(0.0); + let [r, g, b] = self.rgb_from([p[0], p[1], p[2], k]); + *p = [r, g, b, p[3]]; } Ok(Pixels { width: w as u32, height: h as u32, data }) } @@ -958,6 +1196,14 @@ impl Psd { /// One layer's pixels with its layer mask (and vector-less fill colour) applied to alpha. /// `canvas`: document-sized with the layer in place; otherwise the layer's own bounds. pub fn layer_pixels(&self, index: usize, canvas: bool) -> Result { + self.layer_pixels_with_budget(index, canvas, DECODE_BUDGET) + } + + /// [`Self::layer_pixels`] with at most `limit` bytes live at once: the result (16 bytes a + /// pixel) with the channels written straight into it, the CMYK black channel, the one channel + /// being decoded, and the mask. + pub(crate) fn layer_pixels_with_budget(&self, index: usize, canvas: bool, limit: u64) -> Result { + let mut budget = Budget::new(limit); let l = self.layers.get(index).ok_or_else(|| Error::Invalid(format!("no layer {index}")))?; let doc = Rect { top: 0, left: 0, bottom: self.height as i32, right: self.width as i32 }; // Fill layers often have no pixel data: they cover the canvas (or the mask). @@ -971,22 +1217,26 @@ impl Psd { fill_rect }; let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); - let mut out = vec![[0.0f32; 4]; decode_pixels(ow, oh)?]; + let n = decode_pixels(ow, oh)?; let src = fill_rect; let (sw, sh) = (src.width() as usize, src.height() as usize); - if sw > 0 && sh > 0 { - let mut planes: Vec>> = vec![None; self.mode.color_channels()]; - let mut alpha = None; - if l.has_pixels() { - for c in &l.channels { - match c.id { - -1 => alpha = Some(self.decode_channel(c.offset, c.len, sw, sh)?), - id if id >= 0 && (id as usize) < planes.len() => planes[id as usize] = Some(self.decode_channel(c.offset, c.len, sw, sh)?), - _ => {} - } - } + // The peak: the result, then either the CMYK black channel with the channel being decoded, + // or the mask as floats with its bytes. Checked before anything is allocated. + let plane = |w: usize, h: usize| (self.row_bytes(w) as u64).saturating_mul(h as u64); + let mut pixels_peak = 0u64; + if l.has_pixels() && sw > 0 && sh > 0 { + pixels_peak = plane(sw, sh).saturating_add(if self.mode == ColorMode::Cmyk { 4 * n as u64 } else { 0 }); + } + let mut mask_peak = 0u64; + if let Some(m) = l.mask.filter(|m| !m.disabled) { + let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); + if mw > 0 && mh > 0 && l.channels.iter().any(|c| c.id == -2) { + mask_peak = plane(mw, mh).saturating_add((mw as u64).saturating_mul(mh as u64).saturating_mul(4)); } - let fill = l.fill.map(|c| c.map(|v| v as f32)); + } + budget.check((16 * n as u64).saturating_add(pixels_peak.max(mask_peak)))?; + let mut out = budget.filled(n, [0.0f32; 4])?; + if sw > 0 && sh > 0 { // The source rows / columns that land inside the output (64-bit: file bounds can span // the whole i32 range). let span = |s0: i32, n: usize, o0: i32, on: usize| { @@ -997,16 +1247,77 @@ impl Psd { }; let (ys, oy) = span(src.top, sh, out_rect.top, oh); let (xs, ox) = span(src.left, sw, out_rect.left, ow); - for y in ys { - let dy = (y as i64 - oy) as usize; - for x in xs.clone() { - let dx = (x as i64 - ox) as usize; - let i = y * sw + x; - let (rgb, a) = match fill { - Some(f) if !l.has_pixels() => (f, 1.0), - _ => (self.to_rgb(&planes, i), alpha.as_ref().map_or(1.0, |a| a[i])), - }; - out[dy * ow + dx] = [rgb[0], rgb[1], rgb[2], a]; + let dest = |y: usize, x: usize| (y as i64 - oy) as usize * ow + (x as i64 - ox) as usize; + let fill = l.fill.map(|c| c.map(|v| v as f32)); + match fill { + Some(f) if !l.has_pixels() => { + for y in ys { + for x in xs.clone() { + if let Some(p) = out.get_mut(dest(y, x)) { + *p = [f[0], f[1], f[2], 1.0]; + } + } + } + } + _ => { + // Opaque unless there is an alpha channel. + for y in ys.clone() { + for x in xs.clone() { + if let Some(p) = out.get_mut(dest(y, x)) { + p[3] = 1.0; + } + } + } + let cc = self.mode.color_channels(); + let mut black: Option> = None; + if l.has_pixels() { + for c in &l.channels { + let slot = match c.id { + -1 => 3, + id if id >= 0 && (id as usize) < cc => id as usize, + _ => continue, + }; + let raw = self.channel_bytes(c.offset, c.len, sw, sh, &mut budget)?; + // The fourth CMYK channel is the black plate, not a slot of the result. + let to_black = c.id == 3; + if to_black && black.is_none() { + black = Some(budget.filled(out.len(), 0.0f32)?); + } + // Samples arrive in row-major order of the source. + let (mut sy, mut sx) = (0usize, 0usize); + self.for_each_sample(&raw, sw, sh, |_, v| { + if ys.contains(&sy) && xs.contains(&sx) { + let d = dest(sy, sx); + if to_black { + if let Some(o) = black.as_mut().and_then(|k| k.get_mut(d)) { + *o = v; + } + } else if let Some(p) = out.get_mut(d) { + p[slot] = v; + } + } + sx += 1; + if sx == sw { + sx = 0; + sy += 1; + } + }); + release_bytes(&mut budget, raw); + } + } + for y in ys { + for x in xs.clone() { + let d = dest(y, x); + let k = black.as_ref().and_then(|k| k.get(d)).copied().unwrap_or(0.0); + if let Some(p) = out.get_mut(d) { + let [r, g, b] = self.rgb_from([p[0], p[1], p[2], k]); + *p = [r, g, b, p[3]]; + } + } + } + if let Some(k) = black { + budget.release(k.len() as u64 * 4); + } } } } @@ -1015,23 +1326,45 @@ impl Psd { let mc = l.channels.iter().find(|c| c.id == -2); let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); let mask = match mc { - Some(c) if mw > 0 && mh > 0 => Some(self.decode_channel(c.offset, c.len, mw, mh)?), + Some(c) if mw > 0 && mh > 0 => Some(self.channel_floats(c.offset, c.len, mw, mh, &mut budget)?), _ => None, }; let def = m.default_color as f32 / 255.0; - for y in 0..oh { - let py = out_rect.top + y as i32; - for x in 0..ow { - let px = out_rect.left + x as i32; - let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; - let mut v = match (&mask, inside) { - (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], - _ => def, - }; - if m.inverted { - v = 1.0 - v; + // The factor outside the mask rect, or everywhere when there is no mask data. + let outside = if m.inverted { 1.0 - def } else { def }; + let scale = |px: &mut [[f32; 4]], v: f32| { + if v != 1.0 { + for p in px { + p[3] *= v; + } + } + }; + match &mask { + None => scale(&mut out, outside), + Some(mk) => { + // The columns of the output that fall inside the mask rect (the same for every row). + let lo = (i64::from(m.rect.left) - i64::from(out_rect.left)).clamp(0, ow as i64); + let hi = (i64::from(m.rect.right) - i64::from(out_rect.left)).clamp(lo, ow as i64); + let (lo, hi) = (lo as usize, hi as usize); + for (y, row) in out.chunks_exact_mut(ow.max(1)).take(oh).enumerate() { + let py = i64::from(out_rect.top) + y as i64; + #[cfg(test)] + counters::bump(&counters::INSIDE); + if py < i64::from(m.rect.top) || py >= i64::from(m.rect.bottom) { + scale(row, outside); + continue; + } + let (left, rest) = row.split_at_mut(lo); + let (mid, right) = rest.split_at_mut(hi - lo); + scale(left, outside); + scale(right, outside); + // The mask row under `mid`: its first column is output column `lo`. + let base = (py - i64::from(m.rect.top)) as usize * mw + (i64::from(out_rect.left) + lo as i64 - i64::from(m.rect.left)) as usize; + for (i, p) in mid.iter_mut().enumerate() { + let v = mk.get(base + i).copied().unwrap_or(def); + p[3] *= if m.inverted { 1.0 - v } else { v }; + } } - out[y * ow + x][3] *= v; } } } @@ -1039,6 +1372,15 @@ impl Psd { } } +/// The pixel data of a run of planes, as prepared by `Psd::open_planes`. +enum PlaneStream { + Raw, + /// RLE row byte counts of every plane, plane after plane. + Rle(Vec), + /// The inflated ZIP data of all planes. + Zip(Vec), +} + /// The next additional-information block at `*pos` (signature `8BIM`/`8B64`, key, length): /// returns (key, data start, data end). Skips up to 3 padding bytes before a signature. fn next_block(data: &[u8], pos: &mut usize, end: usize, psb: bool) -> Option<([u8; 4], usize, usize)> { @@ -1147,5 +1489,35 @@ fn lab_to_rgb(l: f32, a: f32, b: f32) -> [f32; 3] { [r, g, bb].map(|v| srgb_encode(v.clamp(0.0, 1.0))) } +#[cfg(test)] +mod memory_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_integration; +#[cfg(test)] +mod tests_opt; + +/// Test-only call counters (per thread), to prove what the optimised paths avoid doing. +#[cfg(test)] +pub(crate) mod counters { + use std::cell::Cell; + use std::thread::LocalKey; + + thread_local! { + /// Scratch rows allocated by the 32-bit ZIP un-predictor. + pub static SCRATCH: Cell = const { Cell::new(0) }; + /// `inside the mask rect` evaluations of the layer-mask loop. + pub static INSIDE: Cell = const { Cell::new(0) }; + /// `square_to_quad` solves. + pub static QUAD: Cell = const { Cell::new(0) }; + } + + pub fn bump(c: &'static LocalKey>) { + c.with(|c| c.set(c.get() + 1)); + } + /// Read and reset. + pub fn take(c: &'static LocalKey>) -> usize { + c.with(|c| c.replace(0)) + } +} diff --git a/crates/psd/src/memory_tests.rs b/crates/psd/src/memory_tests.rs new file mode 100644 index 000000000..108046b66 --- /dev/null +++ b/crates/psd/src/memory_tests.rs @@ -0,0 +1,414 @@ +//! Tests for the decode memory budget, with the previous decoder (one `Vec` per channel and a +//! result vector, no budget) kept as the reference the new one must reproduce bit for bit. + +use super::write::*; +use super::*; + +/// The decoder as it was before the budget: copied unchanged except for the names. +impl Psd { + /// Decode one channel of `w`×`h` samples at `offset` (compression field first). Values 0..1. + fn ref_decode_channel(&self, offset: usize, len: usize, w: usize, h: usize) -> Result> { + let end = offset.checked_add(len).filter(|&e| e <= self.data.len()).ok_or(Error::Truncated)?; + let mut r = Reader::at(&self.data[..end], offset); + let comp = r.u16()?; + decode_pixels(w, h)?; + let rowb = self.row_bytes(w); + let raw = match comp { + 0 => r.bytes(rowb * h)?.to_vec(), + 1 => { + let mut counts = Vec::with_capacity(h.min(r.remaining() / 2)); + for _ in 0..h { + counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + let mut out = Vec::with_capacity(rowb * h); + for c in counts { + unpack_bits(r.bytes(c)?, rowb, &mut out)?; + } + out + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + v.resize(rowb * h, 0); + if comp == 3 { + self.unpredict(&mut v, w, h); + } + v + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + }; + Ok(self.ref_samples(&raw, w, h)) + } + + pub(crate) fn ref_samples(&self, raw: &[u8], w: usize, h: usize) -> Vec { + match self.depth { + 1 => { + let rowb = w.div_ceil(8); + let mut out = Vec::with_capacity(w * h); + for y in 0..h { + for x in 0..w { + let b = raw.get(y * rowb + x / 8).copied().unwrap_or(0); + // 1 = black. + out.push(if b & (0x80 >> (x % 8)) != 0 { 0.0 } else { 1.0 }); + } + } + out + } + 16 => raw.as_chunks::<2>().0.iter().take(w * h).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), + // As the previous decoder after the non-finite fix: NaN reads as 0, +inf as 1, -inf as 0. + 32 => raw + .as_chunks::<4>() + .0 + .iter() + .take(w * h) + .map(|c| { + let v = f32::from_be_bytes([c[0], c[1], c[2], c[3]]); + if v.is_nan() || v == f32::NEG_INFINITY { + 0.0 + } else if v == f32::INFINITY { + 1.0 + } else { + v + } + }) + .collect(), + _ => raw.iter().take(w * h).map(|&b| b as f32 / 255.0).collect(), + } + } + + /// Convert colour channels (in the document's mode) to RGB. + pub(crate) fn ref_to_rgb(&self, ch: &[Option>], i: usize) -> [f32; 3] { + let g = |k: usize| ch.get(k).and_then(|c| c.as_ref()).map_or(0.0, |c| c[i]); + let rgb = match self.mode { + ColorMode::Rgb => [g(0), g(1), g(2)], + ColorMode::Cmyk => { + // Stored inverted (1 = no ink). + let k = g(3); + [g(0) * k, g(1) * k, g(2) * k] + } + ColorMode::Indexed if self.palette.len() >= 768 => { + let idx = (g(0) * 255.0).round() as usize & 255; + [self.palette[idx] as f32 / 255.0, self.palette[256 + idx] as f32 / 255.0, self.palette[512 + idx] as f32 / 255.0] + } + ColorMode::Lab => lab_to_rgb(g(0) * 100.0, g(1) * 255.0 - 128.0, g(2) * 255.0 - 128.0), + _ => [g(0); 3], + }; + if self.depth == 32 && self.mode != ColorMode::Lab { rgb.map(srgb_encode) } else { rgb } + } + + /// The merged (flattened) image. + fn ref_composite(&self) -> Result { + let (w, h) = (self.width as usize, self.height as usize); + decode_pixels(w, h)?; + let mut r = Reader::at(&self.data, self.merged_offset); + let comp = r.u16()?; + let nch = self.channels as usize; + let cc = self.mode.color_channels(); + let want = (cc + usize::from(self.merged_alpha)).min(nch); + let rowb = self.row_bytes(w); + let mut planes: Vec>> = vec![None; nch]; + match comp { + 0 => { + for p in planes.iter_mut().take(want) { + *p = Some(self.ref_samples(r.bytes(rowb * h)?, w, h)); + } + } + 1 => { + let mut counts = Vec::with_capacity((nch * h).min(r.remaining() / 2)); + for _ in 0..nch * h { + counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + for (c, p) in planes.iter_mut().enumerate().take(want) { + let mut out = Vec::with_capacity(rowb * h); + for row in 0..h { + unpack_bits(r.bytes(counts[c * h + row])?, rowb, &mut out)?; + } + *p = Some(self.ref_samples(&out, w, h)); + } + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + for (c, p) in planes.iter_mut().enumerate().take(want) { + let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); + if comp == 3 { + self.unpredict(&mut plane, w, h); + } + *p = Some(self.ref_samples(&plane, w, h)); + } + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + } + let alpha = if self.merged_alpha && nch > cc { planes[cc].take() } else { None }; + let mut data = Vec::with_capacity(w * h); + for i in 0..w * h { + let [r, g, b] = self.ref_to_rgb(&planes, i); + let a = alpha.as_ref().map_or(1.0, |a| a[i]); + data.push([r, g, b, a]); + } + Ok(Pixels { width: w as u32, height: h as u32, data }) + } + + /// One layer's pixels with its layer mask (and vector-less fill colour) applied to alpha. + /// `canvas`: document-sized with the layer in place; otherwise the layer's own bounds. + fn ref_layer_pixels(&self, index: usize, canvas: bool) -> Result { + let l = self.layers.get(index).ok_or_else(|| Error::Invalid(format!("no layer {index}")))?; + let doc = Rect { top: 0, left: 0, bottom: self.height as i32, right: self.width as i32 }; + // Fill layers often have no pixel data: they cover the canvas (or the mask). + let fill_rect = + if l.fill.is_some() && !l.has_pixels() { l.mask.filter(|m| !m.rect.is_empty() && m.default_color == 0).map_or(doc, |m| m.rect) } else { l.rect }; + let out_rect = if canvas { + doc + } else if fill_rect.is_empty() { + Rect { top: 0, left: 0, bottom: 1, right: 1 } + } else { + fill_rect + }; + let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); + let mut out = vec![[0.0f32; 4]; decode_pixels(ow, oh)?]; + let src = fill_rect; + let (sw, sh) = (src.width() as usize, src.height() as usize); + if sw > 0 && sh > 0 { + let mut planes: Vec>> = vec![None; self.mode.color_channels()]; + let mut alpha = None; + if l.has_pixels() { + for c in &l.channels { + match c.id { + -1 => alpha = Some(self.ref_decode_channel(c.offset, c.len, sw, sh)?), + id if id >= 0 && (id as usize) < planes.len() => planes[id as usize] = Some(self.ref_decode_channel(c.offset, c.len, sw, sh)?), + _ => {} + } + } + } + let fill = l.fill.map(|c| c.map(|v| v as f32)); + // The source rows / columns that land inside the output (64-bit: file bounds can span + // the whole i32 range). + let span = |s0: i32, n: usize, o0: i32, on: usize| { + let off = i64::from(o0) - i64::from(s0); + let lo = off.clamp(0, n as i64); + let hi = (off + on as i64).clamp(lo, n as i64); + (lo as usize..hi as usize, off) + }; + let (ys, oy) = span(src.top, sh, out_rect.top, oh); + let (xs, ox) = span(src.left, sw, out_rect.left, ow); + for y in ys { + let dy = (y as i64 - oy) as usize; + for x in xs.clone() { + let dx = (x as i64 - ox) as usize; + let i = y * sw + x; + let (rgb, a) = match fill { + Some(f) if !l.has_pixels() => (f, 1.0), + _ => (self.ref_to_rgb(&planes, i), alpha.as_ref().map_or(1.0, |a| a[i])), + }; + out[dy * ow + dx] = [rgb[0], rgb[1], rgb[2], a]; + } + } + } + // Layer mask: multiplies alpha (also outside the mask rect, by its default colour). + if let Some(m) = l.mask.filter(|m| !m.disabled) { + let mc = l.channels.iter().find(|c| c.id == -2); + let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); + let mask = match mc { + Some(c) if mw > 0 && mh > 0 => Some(self.ref_decode_channel(c.offset, c.len, mw, mh)?), + _ => None, + }; + let def = m.default_color as f32 / 255.0; + for y in 0..oh { + let py = out_rect.top + y as i32; + for x in 0..ow { + let px = out_rect.left + x as i32; + let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; + let mut v = match (&mask, inside) { + (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], + _ => def, + }; + if m.inverted { + v = 1.0 - v; + } + out[y * ow + x][3] *= v; + } + } + } + Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) + } +} + +// --------------------------------------------------------------------------------------------- +// Tests + +fn grad(w: u32, h: u32) -> Vec<[f32; 4]> { + (0..w * h) + .map(|i| [(i % w) as f32 / (w - 1).max(1) as f32, (i / w) as f32 / (h - 1).max(1) as f32, 0.25 + (i % 7) as f32 / 16.0, 0.3 + (i % 5) as f32 / 8.0]) + .collect() +} + +/// Layers covering: full canvas pixels with alpha, a partly off-canvas layer, a masked layer with +/// an inverted mask and a default colour, a solid layer. +fn doc(depth: u16, mode: ColorMode, rle: bool) -> WDoc { + let mut d = WDoc::new(40, 30); + d.depth = depth; + d.mode = mode; + d.rle = rle; + let bg = WLayer::pixels("bg", Rect::new(0, 0, 40, 30), grad(40, 30)); + let off = WLayer::pixels("off", Rect::new(-5, -4, 18, 13), grad(18, 13)); + let solid = WLayer::solid("solid", Rect::new(30, 20, 15, 15), [0.2, 0.6, 0.9, 1.0]); + let mut masked = WLayer::pixels("masked", Rect::new(10, 8, 12, 12), grad(12, 12)); + masked.mask = + Some(WMask { rect: Rect::new(12, 10, 12, 12), data: (0..144).map(|i| (i % 12) as f32 / 11.0).collect(), default_color: 255, disabled: false }); + d.layers = vec![bg, off, solid, masked]; + d +} + +fn assert_same_as_reference(p: &Psd, what: &str) { + assert_eq!(p.composite().unwrap(), p.ref_composite().unwrap(), "composite {what}"); + for i in 0..p.layers.len() { + for canvas in [false, true] { + assert_eq!(p.layer_pixels(i, canvas).unwrap(), p.ref_layer_pixels(i, canvas).unwrap(), "layer {i} canvas={canvas} {what}"); + } + } +} + +#[test] +fn pixels_match_the_one_buffer_per_channel_reference() { + for depth in [8u16, 16] { + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] { + for rle in [true, false] { + let p = Psd::parse(write(&doc(depth, mode, rle))).unwrap(); + assert_same_as_reference(&p, &format!("{depth}-bit {mode:?} rle={rle}")); + } + } + } +} + +/// A copy of `bytes` with a different size, depth and merged-image section (`comp`, then `body`). +pub(crate) fn with_merged(bytes: &[u8], size: Option<(u32, u32)>, depth: Option, comp: u16, body: &[u8]) -> Vec { + let offset = Psd::parse(bytes.to_vec()).unwrap().merged_offset; + let mut v = bytes[..offset].to_vec(); + if let Some((w, h)) = size { + v[14..18].copy_from_slice(&h.to_be_bytes()); + v[18..22].copy_from_slice(&w.to_be_bytes()); + } + if let Some(d) = depth { + v[22..24].copy_from_slice(&d.to_be_bytes()); + } + v.extend_from_slice(&comp.to_be_bytes()); + v.extend_from_slice(body); + v +} + +/// Channel planes for a 6×5 image: values that exercise the whole range. +fn planes(nch: usize, bytes_per_sample: usize, n: usize) -> Vec { + let mut v = vec![]; + for c in 0..nch { + for i in 0..n { + let t = ((i * 37 + c * 91) % 251) as f32 / 250.0; + match bytes_per_sample { + 1 => v.push((t * 255.0) as u8), + 2 => v.extend_from_slice(&((t * 65535.0) as u16).to_be_bytes()), + _ => v.extend_from_slice(&(t * 1.5 - 0.1).to_be_bytes()), + } + } + } + v +} + +#[test] +fn merged_image_matches_the_reference_for_32_bit_and_zip() { + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] { + let mut small = WDoc::new(6, 5); + small.mode = mode; + small.rle = false; + let base = write(&small); + let info = Psd::parse(base.clone()).unwrap(); + // The merged image only reads the colour channels and, when flagged, the alpha channel. + let nch = (mode.color_channels() + usize::from(info.merged_alpha)).min(info.channels as usize); + let n = 30; + // 32-bit float, raw. + let p = Psd::parse(with_merged(&base, None, Some(32), 0, &planes(nch, 4, n))).unwrap(); + assert_eq!(p.depth, 32); + assert_eq!(p.composite().unwrap(), p.ref_composite().unwrap(), "32-bit {mode:?}"); + // ZIP without and with prediction, 8 and 16 bit; 32-bit ZIP without prediction. + for (depth, bps) in [(8u16, 1usize), (16, 2), (32, 4)] { + let raw = planes(nch, bps, n); + let zip = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6); + let p = Psd::parse(with_merged(&base, None, Some(depth), 2, &zip)).unwrap(); + assert_eq!(p.composite().unwrap(), p.ref_composite().unwrap(), "zip {depth}-bit {mode:?}"); + if depth == 32 { + continue; + } + // Prediction: each row stores differences to its left neighbour. + let mut pred = raw.clone(); + for plane in pred.chunks_exact_mut(n * bps) { + for row in plane.chunks_exact_mut(6 * bps) { + if bps == 1 { + for i in (1..6).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } else { + for i in (1..6).rev() { + let a = u16::from_be_bytes([row[i * 2], row[i * 2 + 1]]); + let b = u16::from_be_bytes([row[i * 2 - 2], row[i * 2 - 1]]); + row[i * 2..i * 2 + 2].copy_from_slice(&a.wrapping_sub(b).to_be_bytes()); + } + } + } + } + let zip = miniz_oxide::deflate::compress_to_vec_zlib(&pred, 6); + let p = Psd::parse(with_merged(&base, None, Some(depth), 3, &zip)).unwrap(); + assert_eq!(p.composite().unwrap(), p.ref_composite().unwrap(), "zip+prediction {depth}-bit {mode:?}"); + } + } +} + +#[test] +fn budget_counts_the_sum_of_the_buffers_of_a_decode() { + let mut b = Budget::new(100); + b.charge(60).unwrap(); + assert!(b.charge(41).is_err(), "60 + 41 is over 100 although each alone fits"); + b.check(40).unwrap(); + assert!(b.check(41).is_err()); + b.release(30); + b.charge(70).unwrap(); + assert!(b.vec::(1).unwrap_err().to_string().contains("memory budget")); + assert!(Budget::new(u64::MAX).vec::(usize::MAX / 2).is_err(), "a size that cannot be reserved is an error, not an abort"); + + // The same for a real decode: the result (16 bytes a pixel) and a channel's bytes together. + let p = Psd::parse(write(&doc(8, ColorMode::Rgb, true))).unwrap(); + let n = 40 * 30; + let peak = 16 * n + n; + assert!(p.composite_with_budget(peak).is_ok()); + for limit in [peak - 1, 16 * n, 16 * n - 1, 1000, 0] { + let e = p.composite_with_budget(limit).unwrap_err(); + assert!(e.to_string().contains("memory budget"), "{limit}: {e}"); + } + // A layer: the result plus its channel; the mask comes after the channels are gone. + let bg = p.layers.iter().position(|l| l.name == "bg").unwrap(); + assert!(p.layer_pixels_with_budget(bg, true, 16 * n + n).is_ok()); + assert!(p.layer_pixels_with_budget(bg, true, 16 * n).unwrap_err().to_string().contains("memory budget")); + assert_eq!(p.layer_pixels_with_budget(bg, true, 16 * n + n).unwrap(), p.layer_pixels(bg, true).unwrap()); +} + +#[test] +fn a_huge_header_with_a_tiny_zip_stream_is_refused_before_it_allocates() { + // 16384 × 16384, RGB + alpha: a few hundred bytes of zlib that inflate to gigabytes of zeros. + let mut small = WDoc::new(2, 2); + small.rle = false; + let base = write(&small); + let zeros = vec![0u8; 1 << 20]; + let stream = miniz_oxide::deflate::compress_to_vec_zlib(&zeros, 9); + let bytes = with_merged(&base, Some((16384, 16384)), None, 2, &stream); + assert!(bytes.len() < 8192); + let p = Psd::parse(bytes).unwrap(); + assert_eq!((p.width, p.height), (16384, 16384)); + let e = p.composite().unwrap_err(); + assert!(e.to_string().contains("memory budget"), "{e}"); + // The same file with the compression types that read straight from the file. + for comp in [0u16, 1, 3] { + let p = Psd::parse(with_merged(&base, Some((16384, 16384)), None, comp, &stream)).unwrap(); + let e = p.composite().unwrap_err(); + assert!(e.to_string().contains("memory budget"), "comp {comp}: {e}"); + } + // And a smaller image under a small budget: the stream is not inflated. + let p = Psd::parse(with_merged(&base, Some((4096, 4096)), None, 2, &stream)).unwrap(); + assert!(p.composite_with_budget(1 << 20).unwrap_err().to_string().contains("memory budget")); +} diff --git a/crates/psd/src/placed.rs b/crates/psd/src/placed.rs index 156a8e271..dff525a89 100644 --- a/crates/psd/src/placed.rs +++ b/crates/psd/src/placed.rs @@ -12,6 +12,10 @@ use crate::descriptor::{DValue, Descriptor}; use crate::{Pixels, SmartObject}; +/// The most pixels a baked smart object may have: 2^24 (256 MiB as float RGBA). The size comes +/// from the file's transform, which is untrusted. +pub const MAX_BAKE_PIXELS: u64 = 1 << 24; + /// A placed layer's warp. #[derive(Clone, Debug, Default, PartialEq)] pub struct Warp { @@ -35,7 +39,7 @@ pub struct Warp { pub mesh: Vec<[f64; 2]>, } -fn floats(v: Option<&DValue>) -> Vec { +pub(crate) fn floats(v: Option<&DValue>) -> Vec { match v { Some(DValue::UnitFloats(_, v)) => v.clone(), Some(DValue::List(l)) => l @@ -50,6 +54,16 @@ fn floats(v: Option<&DValue>) -> Vec { } } +/// The eight numbers of a transform entry (`Trnf`, `nonAffineTransform`), when it has exactly eight. +pub(crate) fn eight(d: &Descriptor, key: &str) -> Option<[f64; 8]> { + floats(d.get(key)).try_into().ok() +} + +/// The corner quad (top-left, top-right, bottom-right, bottom-left) from x0 y0 … x3 y3. +pub(crate) fn quad_from(t: [f64; 8]) -> [[f64; 2]; 4] { + [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]] +} + impl Warp { /// Read a `warp` descriptor; `None` for no warp. pub fn read(d: &Descriptor) -> Option { @@ -75,8 +89,8 @@ impl Warp { if let Some(DValue::ObjectArray(_, pts)) = env.get("meshPoints") { let xs = floats(pts.get("Hrzn")); let ys = floats(pts.get("Vrtc")); - let n = (3 * cols + 1) * (3 * rows + 1); - if xs.len() == n && ys.len() == n { + let n = cols.checked_mul(3).zip(rows.checked_mul(3)).and_then(|(c, r)| (c + 1).checked_mul(r + 1)); + if n.is_some_and(|n| xs.len() == n && ys.len() == n) { w.cols = cols; w.rows = rows; w.mesh = xs.into_iter().zip(ys).map(|(x, y)| [x, y]).collect(); @@ -94,7 +108,8 @@ impl Warp { let (bw, bh) = (r - l, b - t); let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh); if self.style == "warpCustom" { - return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + // A mesh that does not match cols × rows (public fields) leaves the point where it is. + return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)).unwrap_or(p); } // Styles work in a centred frame (pixels), optionally rotated a quarter turn. let (x, y, fw, fh) = if self.rotate_vertical { (bh * (v - 0.5), -bw * (u - 0.5), bh, bw) } else { (bw * (u - 0.5), bh * (v - 0.5), bw, bh) }; @@ -110,13 +125,16 @@ impl Warp { } /// A point of the custom quilt mesh at (u, v) ∈ [0, 1]². - fn mesh_point(&self, u: f64, v: f64) -> [f64; 2] { + fn mesh_point(&self, u: f64, v: f64) -> Option<[f64; 2]> { let (cols, rows) = (self.cols, self.rows); + if cols == 0 || rows == 0 { + return None; + } let pu = (u * cols as f64).min(cols as f64 - 1e-9); let pv = (v * rows as f64).min(rows as f64 - 1e-9); let (ci, cj) = (pu.floor() as usize, pv.floor() as usize); let (fu, fv) = (pu - ci as f64, pv - cj as f64); - let stride = 3 * cols + 1; + let stride = cols.checked_mul(3)?.checked_add(1)?; let bern = |t: f64| { let s = 1.0 - t; [s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t] @@ -125,13 +143,14 @@ impl Warp { let mut out = [0.0; 2]; for j in 0..4 { for i in 0..4 { - let q = self.mesh[(3 * cj + j) * stride + 3 * ci + i]; + let idx = cj.saturating_mul(3).saturating_add(j).saturating_mul(stride).saturating_add(ci.saturating_mul(3)).saturating_add(i); + let q = self.mesh.get(idx)?; let k = bu[i] * bv[j]; out[0] += q[0] * k; out[1] += q[1] * k; } } - out + Some(out) } } @@ -191,7 +210,9 @@ fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] /// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q` /// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1). -fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { +pub(crate) fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { + #[cfg(test)] + crate::counters::bump(&crate::counters::QUAD); let [p0, p1, p2, p3] = q; let sx = p0[0] - p1[0] + p2[0] - p3[0]; let sy = p0[1] - p1[1] + p2[1] - p3[1]; @@ -206,6 +227,11 @@ fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { [[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]] } +/// The baked size of placed content with document-space bounds `bb` at `k` pixels per document pixel. +fn size_for(bb: [f64; 4], k: f64) -> (u32, u32) { + (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) +} + impl SmartObject { /// Whether the corner quad is a parallelogram (an affine placement). pub fn is_affine(&self) -> bool { @@ -222,11 +248,15 @@ impl SmartObject { /// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad. pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + self.map_with(&square_to_quad(self.quad), p, w, h) + } + + /// [`Self::map`] with the quad's projective matrix already solved (`square_to_quad(self.quad)`). + fn map_with(&self, m: &[[f64; 3]; 3], p: [f64; 2], w: f64, h: f64) -> [f64; 2] { let p = match &self.warp { Some(wp) => wp.apply(p, w, h), None => p, }; - let m = square_to_quad(self.quad); let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9)); let z = m[2][0] * u + m[2][1] * v + m[2][2]; let z = if z.abs() < 1e-12 { 1e-12 } else { z }; @@ -235,11 +265,15 @@ impl SmartObject { /// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content. pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] { + self.bounds_with(&square_to_quad(self.quad), w, h) + } + + fn bounds_with(&self, m: &[[f64; 3]; 3], w: f64, h: f64) -> [f64; 4] { let n = 32; let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; for j in 0..=n { for i in 0..=n { - let p = self.map([i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); + let p = self.map_with(m, [i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; } } @@ -248,24 +282,44 @@ impl SmartObject { /// The baked size for a `w`×`h` content at `k` pixels per document pixel. pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) { + size_for(self.placed_bounds(w, h), k) + } + + /// The scale and size to bake a `w`×`h` content at, wanting `k` pixels per document pixel but + /// never more than [`MAX_BAKE_PIXELS`] pixels, nor more than 4× the `doc` size across either + /// way: a quad corner far outside the document lowers `k` (possibly below 1) instead of asking + /// for a gigantic image. Returns the scale actually used and the size in pixels. + pub fn bake_scale(&self, w: f64, h: f64, k: f64, doc: (u32, u32)) -> (f64, (u32, u32)) { let bb = self.placed_bounds(w, h); - (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) + let (bw, bh) = ((bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0)); + if !(bw.is_finite() && bh.is_finite() && k.is_finite() && k > 0.0) { + return (1.0, (1, 1)); + } + let (max_w, max_h) = ((f64::from(doc.0) * 4.0).max(1.0), (f64::from(doc.1) * 4.0).max(1.0)); + let capped = k.min(max_w / bw).min(max_h / bh).min((MAX_BAKE_PIXELS as f64 / (bw * bh)).sqrt()); + // Rounding up could step over a cap, so a lowered scale rounds down. + let side = |len: f64| if capped < k { (len * capped).floor() } else { (len * capped).ceil() }.clamp(1.0, 16384.0) as u32; + (capped, (side(bw), side(bh))) } /// Bake the content (straight RGBA) as placed: warped and mapped by the quad, rendered at /// `k` pixels per document pixel (into `size` pixels when given; see [`Self::placed_size`]). - /// Returns the pixels and their document-space origin. + /// Returns the pixels and their document-space origin. `None` when the content is empty or + /// the baked image would exceed [`MAX_BAKE_PIXELS`]. pub fn render_placed(&self, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> { let (cw, ch) = (content.width as f64, content.height as f64); if content.width == 0 || content.height == 0 { return None; } - let bb = self.placed_bounds(cw, ch); + // The quad is solved once for the bounds, the size and the whole grid. + let m = square_to_quad(self.quad); + let bb = self.bounds_with(&m, cw, ch); let (ow, oh) = match size { Some((w, h)) => (w.max(1), h.max(1)), - None => self.placed_size(cw, ch, k), + None => size_for(bb, k), }; let (ow, oh) = (ow as usize, oh as usize); + let count = ow.checked_mul(oh).filter(|&c| c as u64 <= MAX_BAKE_PIXELS)?; // A forward grid, filled triangle by triangle with inverse barycentric sampling. let n = 48usize; let mut dst = Vec::with_capacity((n + 1) * (n + 1)); @@ -273,12 +327,14 @@ impl SmartObject { for j in 0..=n { for i in 0..=n { let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch]; - let d = self.map(s, cw, ch); + let d = self.map_with(&m, s, cw, ch); dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]); src.push(s); } } - let mut out = vec![[0.0f32; 4]; ow * oh]; + let mut out: Vec<[f32; 4]> = Vec::new(); + out.try_reserve_exact(count).ok()?; + out.resize(count, [0.0; 4]); let sample = |x: f64, y: f64| -> [f32; 4] { let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0)); let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); @@ -304,6 +360,10 @@ impl SmartObject { if den.abs() < 1e-12 { continue; } + // Loop-invariant per triangle: the source corners and the barycentric coefficients. + let [sa, sb, sc] = tri.map(|t| src[t]); + let (l1y, l1x) = (b[1] - c[1], c[0] - b[0]); + let (l2y, l2x) = (c[1] - a[1], a[0] - c[0]); let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize; let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow); let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize; @@ -311,13 +371,12 @@ impl SmartObject { for y in y0..y1 { for x in x0..x1 { let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); - let l1 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den; - let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den; + let l1 = (l1y * (px - c[0]) + l1x * (py - c[1])) / den; + let l2 = (l2y * (px - c[0]) + l2x * (py - c[1])) / den; let l3 = 1.0 - l1 - l2; if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 { continue; } - let [sa, sb, sc] = tri.map(|t| src[t]); let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3; let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3; out[y * ow + x] = sample(sx, sy); @@ -407,4 +466,48 @@ mod tests { assert!(at(35, 10)[1] > 0.9, "{:?}", at(35, 10)); assert_eq!(at(38, 1)[3], 0.0, "outside the trapezoid"); } + + #[test] + fn custom_warp_with_a_missing_or_short_mesh_does_not_panic() { + let base = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, ..Default::default() }; + // Empty and too-short meshes leave the point where it is. + assert_eq!(base.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]); + let short = Warp { mesh: vec![[0.0, 0.0]; 5], ..base.clone() }; + assert_eq!(short.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]); + for (cols, rows) in [(0, 0), (0, 3), (usize::MAX, 1), (1, usize::MAX), (usize::MAX, usize::MAX)] { + let w = Warp { cols, rows, ..base.clone() }; + assert_eq!(w.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]); + } + } + + #[test] + fn an_absurd_quad_corner_bakes_within_four_times_the_document_and_2_pow_24_pixels() { + let doc = (1920u32, 1080u32); + for far in [1e4, 1e6, 1e12] { + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [far, 5.0], [far, far], [0.0, 20.0]], size: None, warp: None }; + assert!(s.needs_bake()); + // What the importer asks for: the unbounded size is the 16384² clamp, 4 GiB as float RGBA. + assert_eq!(s.placed_size(100.0, 100.0, 4.0), (16384, 16384)); + let (k, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc); + assert!(k > 0.0 && k <= 4.0, "{k}"); + assert!(w <= doc.0 * 4 && h <= doc.1 * 4, "{w}x{h}"); + assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS, "{w}x{h}"); + } + // A quad inside the document keeps the scale and the size it always had. + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [400.0, 50.0], [400.0, 150.0], [0.0, 200.0]], size: None, warp: None }; + let (k, size) = s.bake_scale(100.0, 100.0, 2.0, doc); + assert_eq!((k, size), (2.0, s.placed_size(100.0, 100.0, 2.0))); + // Not-a-number corners stay small too. + let s = SmartObject { uuid: "x".into(), quad: [[f64::NAN; 2]; 4], size: None, warp: None }; + let (_, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc); + assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS); + } + + #[test] + fn render_placed_refuses_an_output_over_the_pixel_cap() { + let content = Pixels { width: 2, height: 1, data: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]] }; + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [40.0, 5.0], [40.0, 15.0], [0.0, 20.0]], size: None, warp: None }; + assert!(s.render_placed(&content, 1.0, Some((16384, 16384))).is_none()); + assert!(s.render_placed(&content, 1.0, Some((u32::MAX, u32::MAX))).is_none()); + } } diff --git a/crates/psd/src/read.rs b/crates/psd/src/read.rs index 97651b2ba..9df7d8b63 100644 --- a/crates/psd/src/read.rs +++ b/crates/psd/src/read.rs @@ -58,6 +58,11 @@ impl<'a> Reader<'a> { pub fn f64(&mut self) -> Result { Ok(f64::from_bits(self.u64()?)) } + /// A big-endian double where NaN and ±infinity read as 0 (they are never meaningful in a file). + pub fn f64_finite(&mut self) -> Result { + let v = self.f64()?; + Ok(if v.is_finite() { v } else { 0.0 }) + } pub fn tag(&mut self) -> Result<[u8; 4]> { let b = self.bytes(4)?; Ok([b[0], b[1], b[2], b[3]]) diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 235163b61..60283b229 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -252,3 +252,241 @@ fn huge_layer_bounds_error_instead_of_allocating() { let p = Psd::parse(bytes).unwrap(); assert!(matches!(p.layer_pixels(0, false), Err(Error::Unsupported(_)))); } + +/// `n` groups nested in each other, each holding one pixel layer (file order: bottom first). +fn nested_groups_doc(n: usize) -> WDoc { + let mut d = WDoc::new(4, 4); + for _ in 0..n { + d.layers.push(WLayer::group_end()); + } + for i in 0..n { + d.layers.push(WLayer::solid(&format!("L{i}"), Rect::new(0, 0, 1, 1), [1.0; 4])); + d.layers.push(WLayer::group(&format!("G{i}"), true, *b"norm")); + } + d +} + +fn depth_of(nodes: &[Node]) -> usize { + nodes.iter().map(|n| if let Node::Group { children, .. } = n { 1 + depth_of(children) } else { 0 }).max().unwrap_or(0) +} + +fn layer_indices(nodes: &[Node], out: &mut Vec) { + for n in nodes { + match n { + Node::Layer(i) => out.push(*i), + Node::Group { layer, children } => { + out.push(*layer); + layer_indices(children, out); + } + } + } +} + +#[test] +fn deeply_nested_groups_are_flattened_at_the_depth_limit() { + assert_eq!(MAX_GROUP_DEPTH, 64); + let n = 70; + let p = Psd::parse(write(&nested_groups_doc(n))).unwrap(); + let (t, flattened) = p.tree_report(); + assert_eq!(depth_of(&t), MAX_GROUP_DEPTH); + assert_eq!(flattened, 6); + // No layer or group record is lost, each appears exactly once (dividers are not nodes). + let mut seen = vec![]; + layer_indices(&t, &mut seen); + seen.sort_unstable(); + assert_eq!(seen, (n..p.layers.len()).collect::>()); + // At the limit nothing is flattened. + let (t, flattened) = Psd::parse(write(&nested_groups_doc(MAX_GROUP_DEPTH))).unwrap().tree_report(); + assert_eq!((depth_of(&t), flattened), (MAX_GROUP_DEPTH, 0)); +} + +#[test] +fn thousands_of_nested_groups_do_not_overflow_a_small_stack() { + let bytes = write(&nested_groups_doc(5000)); + let h = std::thread::Builder::new() + .stack_size(2 << 20) + .spawn(move || { + let p = Psd::parse(bytes).unwrap(); + let t = p.tree(); + let c = t.clone(); + assert_eq!(t, c); + let (_, flattened) = p.tree_report(); + drop(t); + drop(c); + flattened + }) + .unwrap(); + assert_eq!(h.join().unwrap(), 5000 - MAX_GROUP_DEPTH); +} + +#[test] +fn non_finite_transform_and_exposure_values_are_dropped() { + let mut d = WDoc::new(16, 16); + d.layers = vec![ + WLayer::empty("Title", vec![text_block("x", [f64::NAN, f64::INFINITY], "Inter", 12.0, [0.0; 3], 0)]), + WLayer::empty("Exp", vec![exposure_block(f32::NAN, f32::INFINITY, f32::NEG_INFINITY)]), + ]; + let p = Psd::parse(write(&d)).unwrap(); + let t = p.layers[0].text.as_ref().unwrap(); + assert!(t.transform.iter().all(|v| v.is_finite())); + assert_eq!(t.transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + assert_eq!(p.layers[1].adjustment, Some(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 })); +} + +#[test] +fn non_finite_32_bit_samples_become_finite() { + let mut d = WDoc::new(4, 4); + d.depth = 32; + d.layers = vec![WLayer::solid("L", Rect::new(0, 0, 4, 4), [0.5, 0.5, 0.5, 1.0])]; + let p = Psd::parse(write(&d)).unwrap(); + let raw: Vec = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.25].iter().flat_map(|v| v.to_be_bytes()).collect(); + let mut s = vec![f32::NAN; 4]; + p.for_each_sample(&raw, 4, 1, |i, v| s[i] = v); + assert_eq!(s, vec![0.0, 1.0, 0.0, 0.25]); + assert!(p.rgb_from([s[1], s[1], s[1], 0.0]).iter().all(|v| v.is_finite())); +} + +#[test] +fn rect_new_near_i32_max_saturates() { + let r = Rect::new(i32::MAX, i32::MAX, 1, i32::MAX); + assert_eq!((r.right, r.bottom), (i32::MAX, i32::MAX)); + assert!(r.is_empty()); +} + +fn find(bytes: &[u8], needle: &[u8]) -> usize { + bytes.windows(needle.len()).position(|w| w == needle).expect("needle present") +} + +/// The second layer record's signature overwritten. +fn bad_signature() -> Vec { + let mut bytes = write(&doc_with_layers(8, ColorMode::Rgb, true)); + let at = find(&bytes, b"8BIMmul "); + bytes[at] = b'X'; + bytes +} + +/// The first channel of the first layer claims more data than the file holds. +fn bad_channel_length() -> Vec { + let mut bytes = write(&doc_with_layers(8, ColorMode::Rgb, true)); + let at = find(&bytes, b"8BIMnorm"); + let first_len = at - 4 * 6 + 2; + bytes[first_len..first_len + 4].copy_from_slice(&0x7fff_fff0u32.to_be_bytes()); + bytes +} + +#[test] +fn damaged_layer_section_keeps_the_merged_image() { + let good = Psd::parse(write(&doc_with_layers(8, ColorMode::Rgb, true))).unwrap(); + assert!(good.warnings.is_empty() && good.layers.len() == 3); + let want = good.composite().unwrap(); + for bytes in [bad_signature(), bad_channel_length()] { + let p = Psd::parse(bytes).unwrap(); + assert!(p.layers.is_empty()); + assert_eq!(p.warnings.len(), 1, "{:?}", p.warnings); + assert!(p.warnings[0].contains("Layers could not be read")); + assert_eq!((p.width, p.height), (40, 30)); + let got = p.composite().unwrap(); + assert_eq!(got.data, want.data); + } +} + +#[test] +fn header_damage_and_missing_merged_image_still_fail() { + let good = write(&doc_with_layers(8, ColorMode::Rgb, true)); + // Zero width. + let mut b = good.clone(); + b[18..22].copy_from_slice(&0u32.to_be_bytes()); + assert!(Psd::parse(b).is_err()); + // Unknown colour mode. + let mut b = good.clone(); + b[24..26].copy_from_slice(&99u16.to_be_bytes()); + assert!(Psd::parse(b).is_err()); + // Damaged layers and no merged image at all: nothing to fall back to. + let end = Psd::parse(good).unwrap().merged_offset; + let mut b = bad_signature(); + b.truncate(end); + assert!(Psd::parse(b).is_err()); +} + +/// A ZIP plane body for the decoder: the plane delta-coded per row when `predict`. +fn zip_body(raw: &[u8], w: usize, depth: u16, predict: bool) -> Vec { + if !predict { + return raw.to_vec(); + } + let mut out = raw.to_vec(); + let bps = usize::from(depth / 8); + for row in out.chunks_exact_mut(w * bps) { + if depth == 16 { + let mut prev = 0u16; + for c in row.as_chunks_mut::<2>().0 { + let x = u16::from_be_bytes([c[0], c[1]]); + c.copy_from_slice(&x.wrapping_sub(prev).to_be_bytes()); + prev = x; + } + } else { + for i in (1..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } + } + out +} + +/// The same document with every channel and the merged image re-encoded as ZIP (`comp` 2) or +/// ZIP with prediction (3), appended to the file data. +fn rezip(p: &Psd, comp: u16) -> Psd { + let mut z = p.clone(); + let mut data = p.data.to_vec(); + let (depth, predict) = (p.depth, comp == 3); + let deflate = |body: &[u8]| { + let mut v = comp.to_be_bytes().to_vec(); + v.extend(miniz_oxide::deflate::compress_to_vec_zlib(body, 6)); + v + }; + for (l, zl) in p.layers.iter().zip(&mut z.layers) { + for (c, zc) in l.channels.iter().zip(&mut zl.channels) { + let w = if c.id == -2 { l.mask.map_or(0, |m| m.rect.width() as usize) } else { l.rect.width() as usize }; + let raw = &p.data[c.offset + 2..c.offset + c.len]; + zc.offset = data.len(); + data.extend(deflate(&zip_body(raw, w, depth, predict))); + zc.len = data.len() - zc.offset; + } + } + // The merged image: one ZIP stream holding all planes. + let (w, h) = (p.width as usize, p.height as usize); + let plane = w * h * usize::from(depth / 8); + let mut all = vec![]; + for c in 0..usize::from(p.channels) { + let at = p.merged_offset + 2 + c * plane; + all.extend(zip_body(&p.data[at..at + plane], w, depth, predict)); + } + z.merged_offset = data.len(); + data.extend(deflate(&all)); + z.data = data.into(); + z +} + +#[test] +fn zip_channels_decode_like_raw_ones() { + for depth in [8, 16] { + let raw = Psd::parse(write(&doc_with_layers(depth, ColorMode::Rgb, false))).unwrap(); + for comp in [2, 3] { + let zip = rezip(&raw, comp); + assert_eq!(zip.composite().unwrap().data, raw.composite().unwrap().data, "composite {depth} bit, compression {comp}"); + for (i, canvas) in [(0, true), (0, false), (1, false), (2, false), (2, true)] { + let (want, got) = (raw.layer_pixels(i, canvas).unwrap(), zip.layer_pixels(i, canvas).unwrap()); + assert_eq!(got.data, want.data, "layer {i} canvas {canvas}, {depth} bit, compression {comp}"); + } + } + } +} + +#[test] +fn rle_composite_matches_the_raw_one() { + for depth in [8, 16] { + let raw = Psd::parse(write(&doc_with_layers(depth, ColorMode::Rgb, false))).unwrap(); + let rle = Psd::parse(write(&doc_with_layers(depth, ColorMode::Rgb, true))).unwrap(); + assert_eq!(rle.composite().unwrap().data, raw.composite().unwrap().data, "{depth} bit"); + assert_eq!(rle.layer_pixels(2, true).unwrap().data, raw.layer_pixels(2, true).unwrap().data, "{depth} bit"); + } +} diff --git a/crates/psd/src/tests_integration.rs b/crates/psd/src/tests_integration.rs new file mode 100644 index 000000000..a2b48b20c --- /dev/null +++ b/crates/psd/src/tests_integration.rs @@ -0,0 +1,94 @@ +//! The hardening of the Photoshop reader working together: one hostile file (groups nested past +//! the limit, a NaN text transform), a damaged layer section, and a tiny file claiming a huge +//! image. Each ends in a clean result or a clean error, on a 2 MiB stack, never a panic. + +use super::memory_tests::with_merged; +use super::write::*; +use super::*; + +/// Run `f` on a 2 MiB thread; a panic or stack overflow in it fails the test. +fn on_small_stack(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new().stack_size(2 << 20).spawn(f).expect("spawn").join().expect("no panic") +} + +fn depth_of(nodes: &[Node]) -> usize { + nodes.iter().map(|n| if let Node::Group { children, .. } = n { 1 + depth_of(children) } else { 0 }).max().unwrap_or(0) +} + +#[test] +fn a_hostile_file_with_deep_groups_and_a_nan_text_value_loads_and_decodes() { + let n = 2 * MAX_GROUP_DEPTH + 36; + let mut d = WDoc::new(8, 8); + // File order is bottom first: the section ends open the nest, the group headers close it. + d.layers = (0..n).map(|_| WLayer::group_end()).collect(); + for i in 0..n { + d.layers.push(WLayer::solid(&format!("L{i}"), Rect::new(0, 0, 2, 2), [1.0, 0.0, 0.0, 1.0])); + d.layers.push(WLayer::group(&format!("G{i}"), true, *b"norm")); + } + d.layers.push(WLayer::empty("Title", vec![text_block("x", [f64::NAN, f64::INFINITY], "Inter", 12.0, [0.0; 3], 0)])); + let bytes = write(&d); + let (depth, flattened, finite, ok) = on_small_stack(move || { + let p = Psd::parse(bytes).expect("parses"); + let (tree, flattened) = p.tree_report(); + let title = p.layers.iter().find(|l| l.name == "Title").and_then(|l| l.text.as_ref()); + let finite = title.is_some_and(|t| t.transform.iter().all(|v| v.is_finite())); + // Every layer and the merged image decode to a result or an error, and results are finite. + let mut ok = p.composite().map_or(true, |px| px.data.iter().flatten().all(|v| v.is_finite())); + for i in 0..p.layers.len() { + ok &= p.layer_pixels(i, false).map_or(true, |px| px.data.iter().flatten().all(|v| v.is_finite())); + } + (depth_of(&tree), flattened, finite, ok) + }); + assert!(depth <= MAX_GROUP_DEPTH, "{depth}"); + assert!(flattened > 0); + assert!(finite, "the NaN transform fell back to the identity"); + assert!(ok); +} + +#[test] +fn a_damaged_layer_section_falls_back_to_the_merged_image() { + let mut d = WDoc::new(30, 20); + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 30, 20), [0.0, 0.0, 1.0, 1.0]), + WLayer::solid("Box", Rect::new(2, 2, 8, 8), [1.0, 0.0, 0.0, 1.0]).with_blend(b"mul "), + ]; + let mut bytes = write(&d); + let at = bytes.windows(8).position(|w| w == b"8BIMmul ").expect("blend signature"); + bytes[at] = b'X'; + let (layers, warnings, size, composite_ok) = on_small_stack(move || { + let p = Psd::parse(bytes).expect("lenient"); + (p.layers.len(), p.warnings.clone(), (p.width, p.height), p.composite().is_ok()) + }); + assert_eq!(layers, 0); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("Layers could not be read")); + assert_eq!(size, (30, 20)); + assert!(composite_ok); +} + +#[test] +fn a_tiny_file_claiming_a_huge_image_is_refused_within_the_budget() { + let mut small = WDoc::new(2, 2); + small.rle = false; + small.layers = vec![WLayer::solid("L", Rect::new(0, 0, 2, 2), [1.0, 0.0, 0.0, 1.0])]; + let base = write(&small); + let stream = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 1 << 20], 9); + for (w, h, comp) in [(16384u32, 16384u32, 2u16), (16384, 16384, 3), (4096, 4096, 2), (4096, 4096, 3), (16384, 16384, 1), (16384, 16384, 0)] { + let bytes = with_merged(&base, Some((w, h)), None, comp, &stream); + assert!(bytes.len() < 8192); + let outcome = on_small_stack(move || { + let p = Psd::parse(bytes).expect("header and layers parse"); + // A small test budget: one MiB, far under what the claimed image needs. + let merged = p.composite_with_budget(1 << 20).map(|_| ()).map_err(|e| e.to_string()); + let layer = p.layer_pixels_with_budget(0, true, 1 << 20).map(|_| ()).map_err(|e| e.to_string()); + let default_budget = p.composite().map(|_| ()).map_err(|e| e.to_string()); + (merged, layer, default_budget) + }); + let (merged, layer, default_budget) = outcome; + assert!(merged.as_ref().is_err_and(|e| e.contains("memory budget")), "{w}x{h} comp {comp}: {merged:?}"); + assert!(layer.as_ref().is_err_and(|e| e.contains("memory budget")), "{w}x{h} comp {comp}: {layer:?}"); + if w == 16384 { + assert!(default_budget.as_ref().is_err_and(|e| e.contains("memory budget")), "comp {comp}: {default_budget:?}"); + } + } +} diff --git a/crates/psd/src/tests_opt.rs b/crates/psd/src/tests_opt.rs new file mode 100644 index 000000000..f129480c1 --- /dev/null +++ b/crates/psd/src/tests_opt.rs @@ -0,0 +1,649 @@ +//! The optimised decode / mask / placed-bake paths against the implementations they replaced +//! (kept below verbatim as references), and counters for the work they no longer do. + +use super::counters::{self, INSIDE, QUAD, SCRATCH}; +use super::placed::{Warp, square_to_quad}; +use super::write::*; +use super::*; + +// ------------------------------------------------------------------ the replaced code + +fn ref_unpredict(depth: u16, v: &mut [u8], w: usize, h: usize) -> usize { + let mut allocs = 0; + match depth { + 16 => { + for row in v.chunks_exact_mut(w * 2).take(h) { + let mut prev = 0u16; + for c in row.as_chunks_mut::<2>().0 { + let x = u16::from_be_bytes([c[0], c[1]]).wrapping_add(prev); + c.copy_from_slice(&x.to_be_bytes()); + prev = x; + } + } + } + 32 => { + for row in v.chunks_exact_mut(w * 4).take(h) { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + let planar = row.to_vec(); + allocs += 1; + for x in 0..w { + for b in 0..4 { + row[x * 4 + b] = planar[b * w + x]; + } + } + } + } + _ => { + for row in v.chunks_exact_mut(w).take(h) { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + } + } + } + allocs +} + +fn ref_decode_channel(p: &Psd, offset: usize, len: usize, w: usize, h: usize) -> Result> { + let end = offset.checked_add(len).filter(|&e| e <= p.data.len()).ok_or(Error::Truncated)?; + let mut r = Reader::at(&p.data[..end], offset); + let comp = r.u16()?; + decode_pixels(w, h)?; + let rowb = p.row_bytes(w); + let raw = match comp { + 0 => r.bytes(rowb * h)?.to_vec(), + 1 => { + let mut counts = Vec::with_capacity(h.min(r.remaining() / 2)); + for _ in 0..h { + counts.push(if p.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + let mut out = Vec::with_capacity(rowb * h); + for c in counts { + unpack_bits(r.bytes(c)?, rowb, &mut out)?; + } + out + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + v.resize(rowb * h, 0); + if comp == 3 { + ref_unpredict(p.depth, &mut v, w, h); + } + v + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + }; + Ok(p.ref_samples(&raw, w, h)) +} + +fn ref_composite(p: &Psd) -> Result { + let (w, h) = (p.width as usize, p.height as usize); + decode_pixels(w, h)?; + let mut r = Reader::at(&p.data, p.merged_offset); + let comp = r.u16()?; + let nch = p.channels as usize; + let cc = p.mode.color_channels(); + let want = (cc + usize::from(p.merged_alpha)).min(nch); + let rowb = p.row_bytes(w); + let mut planes: Vec>> = vec![None; nch]; + match comp { + 0 => { + for pl in planes.iter_mut().take(want) { + *pl = Some(p.ref_samples(r.bytes(rowb * h)?, w, h)); + } + } + 1 => { + let mut counts = Vec::with_capacity((nch * h).min(r.remaining() / 2)); + for _ in 0..nch * h { + counts.push(if p.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + for (c, pl) in planes.iter_mut().enumerate().take(want) { + let mut out = Vec::with_capacity(rowb * h); + for row in 0..h { + unpack_bits(r.bytes(counts[c * h + row])?, rowb, &mut out)?; + } + *pl = Some(p.ref_samples(&out, w, h)); + } + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + for (c, pl) in planes.iter_mut().enumerate().take(want) { + let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); + if comp == 3 { + ref_unpredict(p.depth, &mut plane, w, h); + } + *pl = Some(p.ref_samples(&plane, w, h)); + } + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + } + let alpha = if p.merged_alpha && nch > cc { planes[cc].take() } else { None }; + let mut data = Vec::with_capacity(w * h); + for i in 0..w * h { + let [r, g, b] = p.ref_to_rgb(&planes, i); + let a = alpha.as_ref().map_or(1.0, |a| a[i]); + data.push([r, g, b, a]); + } + Ok(Pixels { width: w as u32, height: h as u32, data }) +} + +fn ref_layer_pixels(p: &Psd, index: usize, canvas: bool) -> Result { + let l = p.layers.get(index).ok_or_else(|| Error::Invalid(format!("no layer {index}")))?; + let doc = Rect { top: 0, left: 0, bottom: p.height as i32, right: p.width as i32 }; + let fill_rect = + if l.fill.is_some() && !l.has_pixels() { l.mask.filter(|m| !m.rect.is_empty() && m.default_color == 0).map_or(doc, |m| m.rect) } else { l.rect }; + let out_rect = if canvas { + doc + } else if fill_rect.is_empty() { + Rect { top: 0, left: 0, bottom: 1, right: 1 } + } else { + fill_rect + }; + let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); + let mut out = vec![[0.0f32; 4]; decode_pixels(ow, oh)?]; + let src = fill_rect; + let (sw, sh) = (src.width() as usize, src.height() as usize); + if sw > 0 && sh > 0 { + let mut planes: Vec>> = vec![None; p.mode.color_channels()]; + let mut alpha = None; + if l.has_pixels() { + for c in &l.channels { + match c.id { + -1 => alpha = Some(ref_decode_channel(p, c.offset, c.len, sw, sh)?), + id if id >= 0 && (id as usize) < planes.len() => planes[id as usize] = Some(ref_decode_channel(p, c.offset, c.len, sw, sh)?), + _ => {} + } + } + } + let fill = l.fill.map(|c| c.map(|v| v as f32)); + let span = |s0: i32, n: usize, o0: i32, on: usize| { + let off = i64::from(o0) - i64::from(s0); + let lo = off.clamp(0, n as i64); + let hi = (off + on as i64).clamp(lo, n as i64); + (lo as usize..hi as usize, off) + }; + let (ys, oy) = span(src.top, sh, out_rect.top, oh); + let (xs, ox) = span(src.left, sw, out_rect.left, ow); + for y in ys { + let dy = (y as i64 - oy) as usize; + for x in xs.clone() { + let dx = (x as i64 - ox) as usize; + let i = y * sw + x; + let (rgb, a) = match fill { + Some(f) if !l.has_pixels() => (f, 1.0), + _ => (p.ref_to_rgb(&planes, i), alpha.as_ref().map_or(1.0, |a| a[i])), + }; + out[dy * ow + dx] = [rgb[0], rgb[1], rgb[2], a]; + } + } + } + if let Some(m) = l.mask.filter(|m| !m.disabled) { + let mc = l.channels.iter().find(|c| c.id == -2); + let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); + let mask = match mc { + Some(c) if mw > 0 && mh > 0 => Some(ref_decode_channel(p, c.offset, c.len, mw, mh)?), + _ => None, + }; + let def = m.default_color as f32 / 255.0; + for y in 0..oh { + let py = out_rect.top + y as i32; + for x in 0..ow { + let px = out_rect.left + x as i32; + let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; + let mut v = match (&mask, inside) { + (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], + _ => def, + }; + if m.inverted { + v = 1.0 - v; + } + out[y * ow + x][3] *= v; + } + } + } + Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) +} + +fn ref_map(so: &SmartObject, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + let p = match &so.warp { + Some(wp) => wp.apply(p, w, h), + None => p, + }; + let m = square_to_quad(so.quad); + let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9)); + let z = m[2][0] * u + m[2][1] * v + m[2][2]; + let z = if z.abs() < 1e-12 { 1e-12 } else { z }; + [(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z] +} + +fn ref_placed_bounds(so: &SmartObject, w: f64, h: f64) -> [f64; 4] { + let n = 32; + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for j in 0..=n { + for i in 0..=n { + let p = ref_map(so, [i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); + b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; + } + } + [b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()] +} + +fn ref_placed_size(so: &SmartObject, w: f64, h: f64, k: f64) -> (u32, u32) { + let bb = ref_placed_bounds(so, w, h); + (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) +} + +fn ref_render_placed(so: &SmartObject, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> { + let (cw, ch) = (content.width as f64, content.height as f64); + if content.width == 0 || content.height == 0 { + return None; + } + let bb = ref_placed_bounds(so, cw, ch); + let (ow, oh) = match size { + Some((w, h)) => (w.max(1), h.max(1)), + None => ref_placed_size(so, cw, ch, k), + }; + let (ow, oh) = (ow as usize, oh as usize); + let n = 48usize; + let mut dst = Vec::with_capacity((n + 1) * (n + 1)); + let mut src = Vec::with_capacity((n + 1) * (n + 1)); + for j in 0..=n { + for i in 0..=n { + let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch]; + let d = ref_map(so, s, cw, ch); + dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]); + src.push(s); + } + } + let mut out = vec![[0.0f32; 4]; ow * oh]; + let sample = |x: f64, y: f64| -> [f32; 4] { + let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0)); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(content.width as usize - 1), (y0 + 1).min(content.height as usize - 1)); + let (a, b) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let at = |x: usize, y: usize| { + let p = content.data[y * content.width as usize + x]; + [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]] + }; + let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)); + let mut o = [0.0; 4]; + for c in 0..4 { + o[c] = (p00[c] * (1.0 - a) + p10[c] * a) * (1.0 - b) + (p01[c] * (1.0 - a) + p11[c] * a) * b; + } + o + }; + let idx = |i: usize, j: usize| j * (n + 1) + i; + for j in 0..n { + for i in 0..n { + for tri in [[idx(i, j), idx(i + 1, j), idx(i + 1, j + 1)], [idx(i, j), idx(i + 1, j + 1), idx(i, j + 1)]] { + let [a, b, c] = tri.map(|t| dst[t]); + let den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); + if den.abs() < 1e-12 { + continue; + } + let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize; + let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow); + let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize; + let y1 = (a[1].max(b[1]).max(c[1]).ceil() as usize).min(oh); + for y in y0..y1 { + for x in x0..x1 { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let l1 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den; + let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den; + let l3 = 1.0 - l1 - l2; + if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 { + continue; + } + let [sa, sb, sc] = tri.map(|t| src[t]); + let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3; + let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3; + out[y * ow + x] = sample(sx, sy); + } + } + } + } + } + for p in &mut out { + if p[3] > 0.0 { + for c in 0..3 { + p[c] /= p[3]; + } + } + } + Some((Pixels { width: ow as u32, height: oh as u32, data: out }, [bb[0], bb[1]])) +} + +// ------------------------------------------------------------------ fixtures + +fn bits(p: &Pixels) -> Vec<[u32; 4]> { + p.data.iter().map(|px| px.map(f32::to_bits)).collect() +} + +/// Deterministic samples in 0..1 for a `w`×`h` plane, as file bytes at `depth`. +fn synth_plane(depth: u16, w: usize, h: usize, seed: usize) -> Vec { + let mut out = vec![]; + for i in 0..w * h { + let v = ((i * 7 + seed * 13) % 101) as f32 / 100.0; + match depth { + 8 => out.push((v * 255.0).round() as u8), + 16 => out.extend(((v * 65535.0).round() as u16).to_be_bytes()), + _ => out.extend(v.to_be_bytes()), + } + } + out +} + +/// The delta coding that `comp` 3 undoes (the inverse of `Psd::unpredict`). +fn predict(raw: &[u8], w: usize, depth: u16) -> Vec { + let mut out = raw.to_vec(); + match depth { + 16 => { + for row in out.chunks_exact_mut(w * 2) { + let mut prev = 0u16; + for c in row.as_chunks_mut::<2>().0 { + let x = u16::from_be_bytes([c[0], c[1]]); + c.copy_from_slice(&x.wrapping_sub(prev).to_be_bytes()); + prev = x; + } + } + } + 32 => { + for row in out.chunks_exact_mut(w * 4) { + let inter = row.to_vec(); + for x in 0..w { + for b in 0..4 { + row[b * w + x] = inter[x * 4 + b]; + } + } + for i in (1..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } + } + _ => { + for row in out.chunks_exact_mut(w) { + for i in (1..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } + } + } + out +} + +/// A pixel-data stream (compression field first) of consecutive `w`×`h` planes. +fn stream(planes: &[Vec], w: usize, h: usize, depth: u16, comp: u16) -> Vec { + let mut out = comp.to_be_bytes().to_vec(); + let rowb = planes.first().map_or(0, |p| p.len() / h.max(1)); + match comp { + 0 => planes.iter().for_each(|p| out.extend(p)), + 1 => { + let rows: Vec> = planes.iter().flat_map(|p| p.chunks(rowb.max(1)).map(pack_bits)).collect(); + rows.iter().for_each(|r| out.extend((r.len() as u16).to_be_bytes())); + rows.iter().for_each(|r| out.extend(r)); + } + _ => { + let all: Vec = planes.iter().flat_map(|p| if comp == 3 { predict(p, w, depth) } else { p.clone() }).collect(); + out.extend(miniz_oxide::deflate::compress_to_vec_zlib(&all, 6)); + } + } + out +} + +/// A small RGB document with a background, a hidden box and a masked layer (8 bit, raw). +fn base() -> Psd { + let mut d = WDoc::new(40, 30); + d.rle = false; + let grad: Vec<[f32; 4]> = (0..1200).map(|i| [(i % 40) as f32 / 39.0, (i / 40) as f32 / 29.0, 0.25, 1.0]).collect(); + let bg = WLayer::pixels("Background", Rect::new(0, 0, 40, 30), grad); + let mut red = WLayer::solid("Red", Rect::new(5, 6, 10, 8), [1.0, 0.0, 0.0, 0.5]); + red.hidden = true; + let mut masked = WLayer::solid("Masked", Rect::new(20, 10, 12, 12), [0.0, 0.5, 1.0, 1.0]); + let data = (0..144).map(|i| if i % 12 < 6 { 1.0 } else { 0.25 }).collect(); + masked.mask = Some(WMask { rect: Rect::new(20, 10, 12, 12), data, default_color: 0, disabled: false }); + d.layers = vec![bg, red, masked]; + Psd::parse(write(&d)).unwrap() +} + +/// `p` with every channel and the merged image re-encoded at `depth` with compression `comp`. +fn reencode(p: &Psd, depth: u16, comp: u16) -> Psd { + let mut z = p.clone(); + z.depth = depth; + let mut data = p.data.to_vec(); + for (n, (l, zl)) in p.layers.iter().zip(&mut z.layers).enumerate() { + for (c, zc) in l.channels.iter().zip(&mut zl.channels) { + let r = if c.id == -2 { l.mask.map(|m| m.rect).unwrap_or(l.rect) } else { l.rect }; + let (w, h) = (r.width() as usize, r.height() as usize); + zc.offset = data.len(); + data.extend(stream(&[synth_plane(depth, w, h, n * 5 + (c.id + 2) as usize)], w, h, depth, comp)); + zc.len = data.len() - zc.offset; + } + } + let (w, h) = (p.width as usize, p.height as usize); + let planes: Vec> = (0..usize::from(p.channels)).map(|c| synth_plane(depth, w, h, 100 + c)).collect(); + z.merged_offset = data.len(); + data.extend(stream(&planes, w, h, depth, comp)); + z.data = data.into(); + z +} + +fn variants() -> impl Iterator { + [8u16, 16, 32].into_iter().flat_map(|d| [0u16, 1, 2, 3].into_iter().map(move |c| (d, c))) +} + +// ------------------------------------------------------------------ decoding + +#[test] +fn unpredict_matches_the_reference_and_allocates_one_scratch_row() { + for depth in [8u16, 16, 32] { + let p = Psd { depth, ..base() }; + let (w, h) = (13usize, 9usize); + let bps = usize::from(depth / 8); + let mut a: Vec = (0..w * h * bps).map(|i| (i * 31 % 251) as u8).collect(); + let mut b = a.clone(); + counters::take(&SCRATCH); + p.unpredict(&mut a, w, h); + let scratch = counters::take(&SCRATCH); + let old_allocs = ref_unpredict(depth, &mut b, w, h); + assert_eq!(a, b, "{depth} bit"); + // The reference copied every row; the new code uses one scratch row (32 bit only). + assert_eq!((scratch, old_allocs), if depth == 32 { (1, h) } else { (0, 0) }, "{depth} bit"); + } +} + +#[test] +fn raw_planes_are_borrowed_and_zip_planes_are_unpredicted_in_place() { + let p = base(); + // Raw: the sample bytes are a view into the file data. + let q = reencode(&p, 8, 0); + let c = q.layers[0].channels[0]; + let mut r = Reader::at(&q.data, c.offset); + let comp = r.u16().unwrap(); + let plane = q.plane_bytes(comp, &mut r, 40, 30, &mut Budget::new(u64::MAX)).unwrap(); + let Cow::Borrowed(b) = plane else { panic!("raw plane was copied") }; + let file = q.data.as_ptr_range(); + assert!(file.contains(&b.as_ptr()) && b.len() == 1200); + // ZIP: the plane handed out is a sub-slice of the inflated buffer, not a copy. + let mut v: Vec = (0..3 * 40 * 30).map(|i| (i % 255) as u8).collect(); + let range = v.as_ptr_range(); + for comp in [2, 3] { + let got = q.zip_plane(comp, &mut v, 1, 40, 30).unwrap(); + assert_eq!(got.len(), 1200); + assert!(range.contains(&got.as_ptr()), "compression {comp}"); + } + assert!(q.zip_plane(2, &mut v, 3, 40, 30).is_err()); +} + +#[test] +fn decode_and_composite_match_the_reference() { + let p = base(); + for (depth, comp) in variants() { + let q = reencode(&p, depth, comp); + let what = format!("{depth} bit, compression {comp}"); + assert_eq!(bits(&q.composite().unwrap()), bits(&ref_composite(&q).unwrap()), "composite {what}"); + for i in 0..q.layers.len() { + for canvas in [false, true] { + assert_eq!(bits(&q.layer_pixels(i, canvas).unwrap()), bits(&ref_layer_pixels(&q, i, canvas).unwrap()), "layer {i} canvas {canvas}, {what}"); + } + } + } +} + +#[test] +fn errors_match_the_reference_on_damaged_data() { + let p = base(); + for (depth, comp) in variants() { + let mut q = reencode(&p, depth, comp); + // Cut the file in the middle of the merged image / the last channel. + let cut = q.data.len() - 7; + q.data = q.data[..cut].into(); + assert_eq!(q.composite().map(|p| bits(&p)), ref_composite(&q).map(|p| bits(&p)), "{depth} bit, compression {comp}"); + assert_eq!(q.layer_pixels(2, true).map(|p| bits(&p)), ref_layer_pixels(&q, 2, true).map(|p| bits(&p)), "{depth} bit, compression {comp}"); + } +} + +// ------------------------------------------------------------------ the layer mask + +fn rect_at(left: i32, top: i32) -> Rect { + Rect::new(left, top, 12, 12) +} + +#[test] +fn layer_mask_matches_the_reference_for_every_mask_shape() { + let p = base(); + for (depth, comp) in [(8, 1), (16, 2), (32, 3), (8, 0)] { + let q = reencode(&p, depth, comp); + // Moved so it overlaps the layer / canvas partly, wholly, or not at all (the data is 12×12). + for (left, top) in [(20, 10), (14, 4), (-5, -3), (34, 24), (25, 12), (300, 300), (-300, 5)] { + for default_color in [0u8, 255, 90] { + for inverted in [false, true] { + for disabled in [false, true] { + for with_data in [true, false] { + let mut r = q.clone(); + r.layers[2].mask = Some(LayerMask { rect: rect_at(left, top), default_color, disabled, inverted }); + if !with_data { + r.layers[2].channels.retain(|c| c.id != -2); + } + for canvas in [false, true] { + let what = format!( + "{depth} bit comp {comp}, mask at ({left},{top}) default {default_color} inverted {inverted} disabled {disabled} data {with_data} canvas {canvas}" + ); + assert_eq!(bits(&r.layer_pixels(2, canvas).unwrap()), bits(&ref_layer_pixels(&r, 2, canvas).unwrap()), "{what}"); + } + } + } + } + } + } + } +} + +#[test] +fn layer_mask_evaluates_inside_per_row_and_skips_no_ops() { + let q = reencode(&base(), 8, 0); + let with = |default_color, inverted, data: bool| { + let mut r = q.clone(); + r.layers[2].mask = Some(LayerMask { rect: rect_at(20, 10), default_color, disabled: false, inverted }); + if !data { + r.layers[2].channels.retain(|c| c.id != -2); + } + r + }; + // With mask data: one inside test per output row (the reference tested every pixel: 40 × 30). + counters::take(&INSIDE); + with(0, false, true).layer_pixels(2, true).unwrap(); + assert_eq!(counters::take(&INSIDE), 30); + // Own bounds: 12 rows. + with(0, false, true).layer_pixels(2, false).unwrap(); + assert_eq!(counters::take(&INSIDE), 12); + // No mask data: no per-pixel or per-row test at all (a constant factor, skipped when 1.0). + with(255, false, false).layer_pixels(2, true).unwrap(); + with(0, true, false).layer_pixels(2, true).unwrap(); + with(0, false, false).layer_pixels(2, true).unwrap(); + assert_eq!(counters::take(&INSIDE), 0); + // A no-op mask factor leaves the alpha untouched. + let plain = with(255, false, false).layer_pixels(2, false).unwrap(); + let mut none = q.clone(); + none.layers[2].mask = None; + assert_eq!(bits(&plain), bits(&none.layer_pixels(2, false).unwrap())); +} + +// ------------------------------------------------------------------ placed layers + +fn warps() -> Vec> { + let mut mesh = vec![]; + for j in 0..4 { + for i in 0..4 { + mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 2.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]); + } + } + vec![ + None, + Some(Warp { style: "warpArc".into(), bend: 40.0, bounds: [0.0, 0.0, 20.0, 10.0], ..Default::default() }), + Some(Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() }), + ] +} + +fn content(w: u32, h: u32) -> Pixels { + Pixels { + width: w, + height: h, + data: (0..w * h).map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.5, if i % 7 == 0 { 0.4 } else { 1.0 }]).collect(), + } +} + +#[test] +fn placed_bake_matches_the_reference() { + let quads = [ + [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]], + [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], + [[3.0, 4.0], [33.0, 9.0], [28.0, 29.0], [-2.0, 21.0]], + [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + ]; + let c = content(20, 10); + for quad in quads { + for warp in warps() { + let so = SmartObject { uuid: "x".into(), quad, size: None, warp }; + let what = format!("{quad:?} {:?}", so.warp.as_ref().map(|w| &w.style)); + assert_eq!(so.placed_bounds(20.0, 10.0), ref_placed_bounds(&so, 20.0, 10.0), "{what}"); + for k in [0.5, 1.0, 2.0] { + assert_eq!(so.placed_size(20.0, 10.0, k), ref_placed_size(&so, 20.0, 10.0, k), "{what} k {k}"); + for size in [None, Some((30, 25)), Some((0, 0))] { + let got = so.render_placed(&c, k, size); + let want = ref_render_placed(&so, &c, k, size); + assert_eq!( + got.as_ref().map(|(p, o)| (p.width, p.height, bits(p), *o)), + want.as_ref().map(|(p, o)| (p.width, p.height, bits(p), *o)), + "{what} k {k} size {size:?}" + ); + } + } + for p in [[0.0, 0.0], [10.0, 5.0], [20.0, 10.0], [7.5, 2.25]] { + assert_eq!(so.map(p, 20.0, 10.0), ref_map(&so, p, 20.0, 10.0), "{what} {p:?}"); + } + } + } + assert!(SmartObject { uuid: "x".into(), quad: [[0.0; 2]; 4], size: None, warp: None }.render_placed(&content(0, 0), 1.0, None).is_none()); +} + +#[test] +fn placed_bake_solves_the_quad_once() { + let so = SmartObject { uuid: "x".into(), quad: [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]], size: None, warp: None }; + counters::take(&QUAD); + so.placed_bounds(20.0, 10.0); + assert_eq!(counters::take(&QUAD), 1, "33×33 mapped points, one solve (was 1089)"); + so.placed_size(20.0, 10.0, 1.0); + assert_eq!(counters::take(&QUAD), 1); + // The bounds, the size and the 49×49 grid of one bake share a single solve (was 1089 × 2 + 2401). + so.render_placed(&content(20, 10), 1.0, None).unwrap(); + assert_eq!(counters::take(&QUAD), 1); + so.render_placed(&content(20, 10), 1.0, Some((40, 40))).unwrap(); + assert_eq!(counters::take(&QUAD), 1); + // A single `map` still solves for itself. + so.map([1.0, 1.0], 20.0, 10.0); + assert_eq!(counters::take(&QUAD), 1); +} diff --git a/crates/psd/src/write.rs b/crates/psd/src/write.rs index cb3b2bf06..a92e76dcd 100644 --- a/crates/psd/src/write.rs +++ b/crates/psd/src/write.rs @@ -3,7 +3,7 @@ //! additional-information blocks (helpers below build text, effects, fill and adjustment //! blocks). Used to generate test fixtures; not a full exporter. -use crate::descriptor::{self, DValue, Descriptor}; +use crate::descriptor::{self, DValue, Descriptor, put_u16, put_u32, put_utf16}; use crate::{ColorMode, Rect, Section, engine_data, pack_bits}; /// A layer mask for [`WLayer`]. @@ -114,13 +114,6 @@ impl WDoc { } } -fn u16b(out: &mut Vec, v: u16) { - out.extend_from_slice(&v.to_be_bytes()); -} -fn u32b(out: &mut Vec, v: u32) { - out.extend_from_slice(&v.to_be_bytes()); -} - /// Colour channels for a straight RGB pixel in the document mode. fn mode_channels(mode: ColorMode, p: [f32; 4]) -> Vec { match mode { @@ -139,7 +132,7 @@ fn encode_samples(v: &[f32], depth: u16) -> Vec { for &x in v { let x = x.clamp(0.0, 1.0); if depth == 16 { - u16b(&mut out, (x * 65535.0).round() as u16); + put_u16(&mut out, (x * 65535.0).round() as u16); } else { out.push((x * 255.0).round() as u8); } @@ -153,14 +146,14 @@ fn channel_data(v: &[f32], w: usize, h: usize, depth: u16, rle: bool) -> Vec let mut out = vec![]; let rowb = raw.len() / h.max(1); if !rle || w == 0 || h == 0 { - u16b(&mut out, 0); + put_u16(&mut out, 0); out.extend_from_slice(&raw); return out; } - u16b(&mut out, 1); + put_u16(&mut out, 1); let rows: Vec> = raw.chunks(rowb.max(1)).map(pack_bits).collect(); for r in &rows { - u16b(&mut out, r.len() as u16); + put_u16(&mut out, r.len() as u16); } for r in rows { out.extend_from_slice(&r); @@ -180,7 +173,7 @@ fn block(out: &mut Vec, key: &[u8; 4], data: &[u8]) { out.extend_from_slice(b"8BIM"); out.extend_from_slice(key); let padded = data.len().div_ceil(2) * 2; - u32b(out, padded as u32); + put_u32(out, padded as u32); out.extend_from_slice(data); out.resize(out.len() + padded - data.len(), 0); } @@ -208,10 +201,10 @@ fn layer_info(doc: &WDoc) -> Vec { for v in [l.rect.top, l.rect.left, l.rect.bottom, l.rect.right] { out.extend_from_slice(&v.to_be_bytes()); } - u16b(&mut out, chans.len() as u16); + put_u16(&mut out, chans.len() as u16); for (id, d) in &chans { out.extend_from_slice(&id.to_be_bytes()); - u32b(&mut out, d.len() as u32); + put_u32(&mut out, d.len() as u32); } out.extend_from_slice(b"8BIM"); out.extend_from_slice(&l.blend); @@ -226,7 +219,7 @@ fn layer_info(doc: &WDoc) -> Vec { let mut extra = vec![]; match &l.mask { Some(m) => { - u32b(&mut extra, 20); + put_u32(&mut extra, 20); for v in [m.rect.top, m.rect.left, m.rect.bottom, m.rect.right] { extra.extend_from_slice(&v.to_be_bytes()); } @@ -234,9 +227,9 @@ fn layer_info(doc: &WDoc) -> Vec { extra.push(if m.disabled { 2 } else { 0 }); extra.extend_from_slice(&[0, 0]); } - None => u32b(&mut extra, 0), + None => put_u32(&mut extra, 0), } - u32b(&mut extra, 0); // blending ranges + put_u32(&mut extra, 0); // blending ranges pascal4(&mut extra, &l.name); let luni = unicode_plain(&l.name); block(&mut extra, b"luni", &luni); @@ -244,7 +237,7 @@ fn layer_info(doc: &WDoc) -> Vec { Section::Layer => {} s => { let mut d = vec![]; - u32b( + put_u32( &mut d, match s { Section::OpenFolder => 1, @@ -260,7 +253,7 @@ fn layer_info(doc: &WDoc) -> Vec { for (k, d) in &l.blocks { block(&mut extra, k, d); } - u32b(&mut out, extra.len() as u32); + put_u32(&mut out, extra.len() as u32); out.extend_from_slice(&extra); chan_data.push(chans); } @@ -275,13 +268,10 @@ fn layer_info(doc: &WDoc) -> Vec { out } +/// A Unicode string without the terminating NUL (the layer-name `luni` form). fn unicode_plain(s: &str) -> Vec { - let v: Vec = s.encode_utf16().collect(); let mut out = vec![]; - u32b(&mut out, v.len() as u32); - for c in v { - u16b(&mut out, c); - } + put_utf16(&mut out, s, false); out } @@ -313,58 +303,58 @@ pub fn write(doc: &WDoc) -> Vec { let cc = doc.mode.color_channels(); let mut out = vec![]; out.extend_from_slice(b"8BPS"); - u16b(&mut out, 1); + put_u16(&mut out, 1); out.extend_from_slice(&[0; 6]); - u16b(&mut out, (cc + 1) as u16); - u32b(&mut out, doc.height); - u32b(&mut out, doc.width); - u16b(&mut out, doc.depth); - u16b(&mut out, doc.mode.to_u16()); - u32b(&mut out, 0); // colour mode data - u32b(&mut out, 0); // image resources + put_u16(&mut out, (cc + 1) as u16); + put_u32(&mut out, doc.height); + put_u32(&mut out, doc.width); + put_u16(&mut out, doc.depth); + put_u16(&mut out, doc.mode.to_u16()); + put_u32(&mut out, 0); // colour mode data + put_u32(&mut out, 0); // image resources // Layer and mask information. let mut lm = vec![]; if !doc.layers.is_empty() { let li = layer_info(doc); if doc.depth == 16 { - u32b(&mut lm, 0); - u32b(&mut lm, 0); // global layer mask info + put_u32(&mut lm, 0); + put_u32(&mut lm, 0); // global layer mask info let mut padded = li; while !padded.len().is_multiple_of(4) { padded.push(0); } lm.extend_from_slice(b"8BIM"); lm.extend_from_slice(b"Lr16"); - u32b(&mut lm, padded.len() as u32); + put_u32(&mut lm, padded.len() as u32); lm.extend_from_slice(&padded); } else { - u32b(&mut lm, li.len() as u32); + put_u32(&mut lm, li.len() as u32); lm.extend_from_slice(&li); - u32b(&mut lm, 0); + put_u32(&mut lm, 0); } if !doc.linked.is_empty() { let d = linked_block(&doc.linked); lm.extend_from_slice(b"8BIM"); lm.extend_from_slice(b"lnk2"); - u32b(&mut lm, d.len() as u32); + put_u32(&mut lm, d.len() as u32); lm.extend_from_slice(&d); } } - u32b(&mut out, lm.len() as u32); + put_u32(&mut out, lm.len() as u32); out.extend_from_slice(&lm); // Merged image. let comp = doc.composite.clone().unwrap_or_else(|| flatten(doc)); - let (w, h) = (doc.width as usize, doc.height as usize); + let w = doc.width as usize; let planes: Vec> = comp.iter().map(|p| mode_channels(doc.mode, *p)).collect(); let mut chans: Vec> = (0..cc).map(|c| planes.iter().map(|v| v[c]).collect()).collect(); chans.push(comp.iter().map(|p| p[3]).collect()); if doc.rle { - u16b(&mut out, 1); + put_u16(&mut out, 1); let rows: Vec>> = chans.iter().map(|c| encode_samples(c, doc.depth).chunks((w * if doc.depth == 16 { 2 } else { 1 }).max(1)).map(pack_bits).collect()).collect(); for c in &rows { for r in c { - u16b(&mut out, r.len() as u16); + put_u16(&mut out, r.len() as u16); } } for c in rows { @@ -373,12 +363,11 @@ pub fn write(doc: &WDoc) -> Vec { } } } else { - u16b(&mut out, 0); + put_u16(&mut out, 0); for c in &chans { out.extend_from_slice(&encode_samples(c, doc.depth)); } } - let _ = h; out } @@ -388,7 +377,7 @@ fn linked_block(files: &[(String, String, [u8; 4], Vec)]) -> Vec { for (uuid, name, ty, data) in files { let mut rec = vec![]; rec.extend_from_slice(b"liFD"); - u32b(&mut rec, 7); + put_u32(&mut rec, 7); rec.push(uuid.len() as u8); rec.extend_from_slice(uuid.as_bytes()); rec.extend_from_slice(&unicode_plain(name)); @@ -397,7 +386,7 @@ fn linked_block(files: &[(String, String, [u8; 4], Vec)]) -> Vec { rec.extend_from_slice(&(data.len() as u64).to_be_bytes()); rec.push(0); // no file-open descriptor rec.extend_from_slice(data); - u32b(&mut rec, 0); // child document id (empty unicode string) + put_u32(&mut rec, 0); // child document id (empty unicode string) rec.extend_from_slice(&0f64.to_be_bytes()); // asset modification time rec.push(0); // asset locked state out.extend_from_slice(&(rec.len() as u64).to_be_bytes()); @@ -462,8 +451,8 @@ pub fn smart_object_block_warped(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2] d = d.with("warp", DValue::Descriptor(w)); } let mut out = b"soLD".to_vec(); - u32b(&mut out, 4); - u32b(&mut out, 16); + put_u32(&mut out, 4); + put_u32(&mut out, 16); descriptor::write_descriptor(&mut out, &d); (*b"SoLd", out) } @@ -471,12 +460,12 @@ pub fn smart_object_block_warped(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2] /// `TySh`: a type layer with one style run; the text origin sits at `origin`. pub fn text_block(text: &str, origin: [f64; 2], font: &str, size: f64, color: [f64; 3], justification: u8) -> ([u8; 4], Vec) { let mut d = vec![]; - u16b(&mut d, 1); + put_u16(&mut d, 1); for v in [1.0, 0.0, 0.0, 1.0, origin[0], origin[1]] { d.extend_from_slice(&f64::to_be_bytes(v)); } - u16b(&mut d, 50); - u32b(&mut d, 16); + put_u16(&mut d, 50); + put_u32(&mut d, 16); let desc = Descriptor::new("TxLr") .with("Txt ", DValue::Text(text.replace('\n', "\r"))) .with("textGridding", DValue::Enum("textGridding".into(), "None".into())) @@ -484,11 +473,11 @@ pub fn text_block(text: &str, origin: [f64; 2], font: &str, size: f64, color: [f .with("EngineData", DValue::Raw(engine_data::minimal(text, font, size, color, justification))); descriptor::write_descriptor(&mut d, &desc); // Warp: version, descriptor version, an empty warp descriptor. - u16b(&mut d, 1); - u32b(&mut d, 16); + put_u16(&mut d, 1); + put_u32(&mut d, 16); descriptor::write_descriptor(&mut d, &Descriptor::new("warp").with("warpStyle", DValue::Enum("warpStyle".into(), "warpNone".into()))); for _ in 0..4 { - u32b(&mut d, 0); + put_u32(&mut d, 0); } (*b"TySh", d) } @@ -496,8 +485,8 @@ pub fn text_block(text: &str, origin: [f64; 2], font: &str, size: f64, color: [f /// `lfx2`: layer effects from an effects descriptor (`null` class with `DrSh`, `FrFX`…). pub fn effects_block(desc: &Descriptor) -> ([u8; 4], Vec) { let mut d = vec![]; - u32b(&mut d, 0); - u32b(&mut d, 16); + put_u32(&mut d, 0); + put_u32(&mut d, 16); descriptor::write_descriptor(&mut d, desc); (*b"lfx2", d) } @@ -505,7 +494,7 @@ pub fn effects_block(desc: &Descriptor) -> ([u8; 4], Vec) { /// `SoCo`: solid colour fill. pub fn solid_color_block(rgb: [f64; 3]) -> ([u8; 4], Vec) { let mut d = vec![]; - u32b(&mut d, 16); + put_u32(&mut d, 16); descriptor::write_descriptor(&mut d, &Descriptor::new("null").with("Clr ", DValue::Descriptor(Descriptor::rgb(rgb)))); (*b"SoCo", d) } @@ -513,7 +502,7 @@ pub fn solid_color_block(rgb: [f64; 3]) -> ([u8; 4], Vec) { /// `hue2`: Hue/Saturation (master). pub fn hue_sat_block(hue: i16, sat: i16, light: i16) -> ([u8; 4], Vec) { let mut d = vec![]; - u16b(&mut d, 2); + put_u16(&mut d, 2); d.extend_from_slice(&[0, 0]); for v in [0i16, 0, 0, hue, sat, light] { d.extend_from_slice(&v.to_be_bytes()); @@ -526,7 +515,7 @@ pub fn hue_sat_block(hue: i16, sat: i16, light: i16) -> ([u8; 4], Vec) { /// `levl`: Levels (composite record). pub fn levels_block(in_black: i16, in_white: i16, out_black: i16, out_white: i16, gamma: f64) -> ([u8; 4], Vec) { let mut d = vec![]; - u16b(&mut d, 2); + put_u16(&mut d, 2); for v in [in_black, in_white, out_black, out_white, (gamma * 100.0).round() as i16] { d.extend_from_slice(&v.to_be_bytes()); } @@ -553,7 +542,7 @@ pub fn brightness_block(brightness: i16, contrast: i16) -> ([u8; 4], Vec) { pub fn exposure_block(exposure: f32, offset: f32, gamma: f32) -> ([u8; 4], Vec) { let mut d = vec![]; - u16b(&mut d, 1); + put_u16(&mut d, 1); for v in [exposure, offset, gamma] { d.extend_from_slice(&v.to_be_bytes()); } @@ -563,18 +552,18 @@ pub fn exposure_block(exposure: f32, offset: f32, gamma: f32) -> ([u8; 4], Vec], width: u32, height: u32, inverted: bool) -> ([u8; 4], Vec) { let mut d = vec![]; - u32b(&mut d, 3); - u32b(&mut d, inverted as u32); + put_u32(&mut d, 3); + put_u32(&mut d, inverted as u32); // Path fill rule record. - u16b(&mut d, 6); + put_u16(&mut d, 6); d.resize(d.len() + 24, 0); let fx = |v: f64| ((v * (1 << 24) as f64).round() as i32).to_be_bytes(); for sp in subpaths { - u16b(&mut d, 0); - u16b(&mut d, sp.len() as u16); + put_u16(&mut d, 0); + put_u16(&mut d, sp.len() as u16); d.resize(d.len() + 22, 0); for k in sp { - u16b(&mut d, 1); + put_u16(&mut d, 1); for p in k { d.extend_from_slice(&fx(p[1] / height as f64)); d.extend_from_slice(&fx(p[0] / width as f64));