From af010b6dec66c8a1a4d3f14b0b32b5e825b683aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 15:55:34 -0800 Subject: [PATCH 1/3] PSD: a file's clipping path clips its merged image on Open and Place A PSD or PSB file can name one of its saved paths (image resources 2000 to 2997) as its clipping path in resource 2999. psdread::clipping_path reads that path in the image's pixel space, under the even-odd rule the file format specification gives, with a subpath around the whole image first when the initial fill record says the fill starts with all pixels. File > Place of such a file, and File > Open of its merged image, put the image in a clip group of that path with no paint. file.place answers the image's id as `image`, as for a DCS file. Replacing an image alone in a clip group with such a file keeps the image's scale and rotation. links.update and links.relink leave the clip group as it is. Files that name no clipping path come in as before. --- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 18 +- crates/engine/src/cmd/fileio/psdread.rs | 121 +++++++++-- crates/engine/src/cmd/fileio/tests_psdread.rs | 199 +++++++++++++++++- crates/engine/src/cmd/place.rs | 49 +++-- crates/engine/tests/import_fuzz.rs | 74 +++++++ docs/file-format-parity.md | 2 +- docs/mcp.md | 10 + 8 files changed, 437 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6995075d2f..6d972beda8 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -39,7 +39,7 @@ pub mod jxlread; mod legacy_ai; mod load; mod mesh3d; -pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; +pub(crate) use load::{MAX_RASTER_ALLOC, file_clipped, size_note}; mod heifread; mod hpgl; mod jp2read; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1a3c4c6e2c..e6eab02ad6 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -1059,16 +1059,32 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result { let layer = d.layers[0].id; let id = d.alloc_id(); let xf = Affine::scale_non_uniform(sx, sy); - let mut n = Node::new( + let n = Node::new( id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })), ); + let mut n = file_clipped(&mut d, n, psdread::clipping_path(bytes)); n.name = Some(name.to_string()); d.images.insert(key, blob); d.insert(Some(layer), 0, n).map_err(err)?; Ok(d) } +/// Image `image` of `d` clipped by `clip`, the clipping path its file names in the image's pixel +/// space (a PSD or PSB file's, [`psdread::clipping_path`]): a clip group of that path (even-odd, +/// with no paint) and the image. Without one the image stays as it is. +pub(crate) fn file_clipped(d: &mut Document, image: Node, clip: Option) -> Node { + let (Some(path), NodeKind::Image(im)) = (clip, &image.kind) else { return image }; + let path = path.transformed(im.xf); + let none = vectorcraft_color::Paint::None; + let mut clip = Node::path(d.alloc_id(), path, vectorcraft_doc::Appearance::basic(none.clone(), none, 0.0)); + if let NodeKind::Path { clipping, rule, .. } = &mut clip.kind { + *clipping = true; + *rule = vectorcraft_geom::FillRule::EvenOdd; + } + Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(image)], clip: true }) +} + /// Does this import note say that the file has something the document doesn't? fn is_loss(note: &str) -> bool { note == vectorcraft_pdf::OFF_ARTBOARD_NOTE || vectorcraft_eps::is_loss(note) diff --git a/crates/engine/src/cmd/fileio/psdread.rs b/crates/engine/src/cmd/fileio/psdread.rs index 93e7b61f17..28b44d6197 100644 --- a/crates/engine/src/cmd/fileio/psdread.rs +++ b/crates/engine/src/cmd/fileio/psdread.rs @@ -2,7 +2,8 @@ //! File › Place (#918): the merged image, the flattened picture every PSD carries after its layers, //! as a linked PSD shows. Written from Adobe's public "Photoshop File Formats Specification" //! (). File › Open reads the -//! layers with [`super::psdlayers`], which shares this reader. +//! layers with [`super::psdlayers`], which shares this reader. The clipping path a document names +//! ([`clipping_path`]) clips its merged image where it opens or is placed. //! //! Every mode Photoshop saves a merged image in is read: Bitmap, Grayscale, Duotone (as its //! grayscale data), Indexed, RGB, CMYK and Lab, at 1, 8, 16 and 32 bits per channel, stored raw, @@ -14,6 +15,7 @@ use std::io::Read; use vectorcraft_color::cms::{Lab, lab::lab_to_srgb}; +use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath}; /// Photoshop's colour modes (the header's numbers), as far as the merged image is concerned. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -154,30 +156,113 @@ pub(super) fn header<'a>(r: &mut Reader<'a>) -> Result, String> { pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { let mut r = Reader::new(bytes); let h = header(&mut r).ok()?; - let mut r = Reader::new(h.resources); - while !r.rest().is_empty() { - r.take(4).ok()?; + let (_, _, data) = resources(h.resources, false).find(|(id, _, _)| *id == 0x03ed)?; + let mut d = Reader::new(data); + // Fixed 16.16 values, each with its unit (1: per inch, 2: per centimetre) and the unit the + // size is shown in. + let mut axis = || -> Option { + let v = f64::from(d.u32().ok()?) / 65536.0; + let unit = d.u16().ok()?; + d.u16().ok()?; + Some(if unit == 2 { v * 2.54 } else { v }) + }; + Some((axis()?, axis()?)) +} + +/// The image resource blocks of `resources`, in file order: each one's id, name (the bytes of its +/// Pascal string) and data. Stops at the first block that is cut short. With `signed`, only the +/// blocks whose signature is `8BIM` are given. +fn resources(resources: &[u8], signed: bool) -> impl Iterator { + let mut r = Reader::new(resources); + std::iter::from_fn(move || { + if r.rest().is_empty() { + return None; + } + let ours = r.take(4).ok()? == b"8BIM"; let id = r.u16().ok()?; // A Pascal string padded to an even size, length byte included. - let name = usize::from(r.u8().ok()?); - r.take(name + (name + 1) % 2).ok()?; + let len = usize::from(r.u8().ok()?); + let name = r.take(len).ok()?; + r.take((len + 1) % 2).ok()?; let size = usize::try_from(r.u32().ok()?).ok()?; let data = r.take(size).ok()?; r.take(size % 2).ok()?; - if id == 0x03ed { - let mut d = Reader::new(data); - // Fixed 16.16 values, each with its unit (1: per inch, 2: per centimetre) and the - // unit the size is shown in. - let mut axis = || -> Option { - let v = f64::from(d.u32().ok()?) / 65536.0; - let unit = d.u16().ok()?; - d.u16().ok()?; - Some(if unit == 2 { v * 2.54 } else { v }) - }; - return Some((axis()?, axis()?)); + Some((ours, id, name, data)) + }) + .filter(move |(ours, ..)| *ours || !signed) + .map(|(_, id, name, data)| (id, name, data)) +} + +/// The resource naming the document's clipping path. +const CLIPPING_PATH_NAME: u16 = 2999; + +/// The saved paths' resource ids. +const SAVED_PATHS: std::ops::RangeInclusive = 2000..=2997; + +/// The clipping path a PSD or PSB file names, in its pixel space (x across its width, y down +/// its height): the saved path (resources 2000 to 2997) whose name the clipping path resource +/// (2999) gives, both `8BIM` resources. `None` when the document names none, names a path it doesn't have, or that path +/// has no anchors or more records than [`vectorcraft_geom::path::MAX_READ_ANCHORS`]. Saved paths +/// use the even-odd rule. A path whose initial fill record says the fill starts with all pixels +/// begins with a subpath around the whole image, so its subpaths cut holes in it. +pub fn clipping_path(bytes: &[u8]) -> Option { + let mut r = Reader::new(bytes); + let h = header(&mut r).ok()?; + let (_, _, named) = resources(h.resources, true).find(|(id, _, _)| *id == CLIPPING_PATH_NAME)?; + // A Pascal string, then the flatness and a fill rule, which the specification says is ignored. + let len = usize::from(*named.first()?); + let name = named.get(1..1 + len)?; + let (_, _, data) = resources(h.resources, true).find(|(id, n, _)| SAVED_PATHS.contains(id) && *n == name)?; + // A path of more records than a document may hold anchors is left out before it is built. + if data.len() / PATH_RECORD > vectorcraft_geom::path::MAX_READ_ANCHORS { + return None; + } + let path = saved_path(data, f64::from(h.width), f64::from(h.height)); + (!path.is_empty()).then_some(path) +} + +/// One record of a saved path: a selector, then 24 bytes. +const PATH_RECORD: usize = 26; + +/// The saved path of path records `data`, scaled to an image `width` × `height` pixels. Knot +/// records come after their subpath's length record; one that comes before any, or past the knots +/// a length record counts, is left out, as is a last record cut short. +fn saved_path(data: &[u8], width: f64, height: f64) -> PathData { + // A path point: a vertical, then a horizontal signed 8.24 fixed-point fraction of the image's + // height and width. + let fixed = |b: [u8; 4]| f64::from(i32::from_be_bytes(b)) / f64::from(1 << 24); + let point = |[v0, v1, v2, v3, h0, h1, h2, h3]: [u8; 8]| Point::new(fixed([h0, h1, h2, h3]) * width, fixed([v0, v1, v2, v3]) * height); + let mut subpaths: Vec = vec![]; + let mut all_pixels = false; + // The knots the current subpath still takes. + let mut left = 0usize; + let (records, _) = data.as_chunks::(); + for &[s0, s1, ref body @ ..] in records { + let [c0, c1, ..] = *body; + let (points, _) = body.as_chunks::<8>(); + let [a, b, c] = [0, 1, 2].map(|i| points.get(i).copied().map_or(Point::ZERO, point)); + match u16::from_be_bytes([s0, s1]) { + sel @ (0 | 3) => { + left = usize::from(u16::from_be_bytes([c0, c1])); + // Anchors are added as knots come: a count can claim more than follow. + subpaths.push(SubPath::new(vec![], sel == 0)); + } + sel @ (1 | 2 | 4 | 5) if left > 0 => { + let Some(sp) = subpaths.last_mut() else { continue }; + left -= 1; + let kind = if matches!(sel, 1 | 4) { AnchorKind::Smooth } else { AnchorKind::Corner }; + sp.anchors.push(Anchor { h_in: a, p: b, h_out: c, kind }); + } + 8 => all_pixels = [c0, c1] == [0, 1], + _ => {} } } - None + subpaths.retain(|s| !s.anchors.is_empty()); + if all_pixels && !subpaths.is_empty() { + let all = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, width, height)); + subpaths.splice(0..0, all.subpaths); + } + PathData::new(subpaths) } /// Does the merged image keep its transparency in the first extra channel? Yes when the layer diff --git a/crates/engine/src/cmd/fileio/tests_psdread.rs b/crates/engine/src/cmd/fileio/tests_psdread.rs index 08a46f0e10..4fe5f1e49b 100644 --- a/crates/engine/src/cmd/fileio/tests_psdread.rs +++ b/crates/engine/src/cmd/fileio/tests_psdread.rs @@ -1,9 +1,11 @@ //! Reading Photoshop documents (#918): the merged image of every colour mode, depth and -//! compression, PSB files, transparency, the resolution, and damaged files refused without a panic. +//! compression, PSB files, transparency, the resolution, the clipping path, and damaged files +//! refused without a panic. use serde_json::{Value, json}; +use vectorcraft_doc::{Document, NodeId, NodeKind}; -use super::psdread::{decode, is_psd, resolution}; +use super::psdread::{clipping_path, decode, is_psd, resolution}; use super::*; /// A Photoshop document built from its parts: `version` 1 (PSD) or 2 (PSB), the header's fields, @@ -260,6 +262,199 @@ fn pdd_files_open_and_place() { assert!(format_for_name("x.pdd").is_none() && writable_format(None, Some("x.pdd")).is_err(), "an export to .pdd is refused as before"); } +/// An image resource block: `8BIM`, `id`, `name` as a Pascal string padded to an even size, then +/// `data` padded to an even size. +fn resource(id: u16, name: &[u8], data: &[u8]) -> Vec { + let mut r = b"8BIM".to_vec(); + r.extend(id.to_be_bytes()); + r.push(name.len() as u8); + r.extend(name); + if name.len().is_multiple_of(2) { + r.push(0); + } + r.extend((data.len() as u32).to_be_bytes()); + r.extend(data); + if !data.len().is_multiple_of(2) { + r.push(0); + } + r +} + +/// A path point record's 8 bytes: `[x, y]` as fractions of the image's width and height, stored +/// vertical first as signed 8.24 fixed-point numbers. +fn point([x, y]: [f64; 2]) -> Vec { + [y, x].iter().flat_map(|v| ((v * f64::from(1 << 24)).round() as i32).to_be_bytes()).collect() +} + +/// A saved path's records: the fill rule record, an initial fill record saying whether the fill +/// starts with all pixels, then each subpath (closed or not) of corner knots without handles. +fn saved_path(all_pixels: bool, subpaths: &[(bool, &[[f64; 2]])]) -> Vec { + let record = |selector: u16, body: Vec| { + let mut r = selector.to_be_bytes().to_vec(); + r.extend(body); + r.resize(26, 0); + r + }; + let mut d = record(6, vec![]); + d.extend(record(8, vec![0, u8::from(all_pixels)])); + for (closed, knots) in subpaths { + d.extend(record(if *closed { 0 } else { 3 }, (knots.len() as u16).to_be_bytes().to_vec())); + for k in *knots { + d.extend(record(if *closed { 2 } else { 5 }, [point(*k), point(*k), point(*k)].concat())); + } + } + d +} + +/// The clipping path resource (2999) naming the saved path `name`, with a flatness of 0 and the +/// fill rule left as it is. +fn clipping_name(name: &[u8]) -> Vec { + let mut d = vec![name.len() as u8]; + d.extend(name); + d.extend([0; 6]); + resource(2999, b"", &d) +} + +/// A 100 × 50 pixel red RGB document with `resources`, flat or with a layer count of 0. +fn red_psd(resources: &[u8]) -> Vec { + psd(1, 3, (100, 50), 8, 3, &[], resources, &[], &raw(&[&[255; 5000], &[0; 5000], &[0; 5000]])) +} + +/// The triangle saved as "Outline", another path saved as "Other", and the clipping path naming +/// "Outline". +fn triangle_resources() -> Vec { + let other = saved_path(false, &[(true, &[[0.0, 0.0], [0.1, 0.0], [0.1, 0.1]])]); + let outline = saved_path(false, &[(true, &[[0.5, 0.0], [1.0, 1.0], [0.0, 1.0]])]); + [resource(2000, b"Other", &other), resource(2001, b"Outline", &outline), clipping_name(b"Outline")].concat() +} + +/// The clip group a PSD file comes in as: its clipping path's anchors (pt), whether that path +/// uses the even-odd rule, and the image it clips. +fn clip_group(d: &Document, id: NodeId) -> (Vec<[f64; 2]>, bool, NodeId) { + let Some(NodeKind::Group { children, clip: true }) = d.node(id).map(|n| &n.kind) else { panic!("not a clip group: {:?}", d.node(id)) }; + let [path, image] = &children[..] else { panic!("{} children", children.len()) }; + let NodeKind::Path { path: p, rule, clipping: true, .. } = &path.kind else { panic!("not a clipping path") }; + assert!(matches!(image.kind, NodeKind::Image(_))); + let anchors = p.anchors().map(|(_, _, a)| [a.p.x, a.p.y]).collect(); + (anchors, *rule == vectorcraft_geom::FillRule::EvenOdd, image.id) +} + +#[test] +fn the_clipping_path_clips_the_merged_image_on_place_and_open() { + let f = red_psd(&triangle_resources()); + let triangle = vec![[50.0, 0.0], [100.0, 50.0], [0.0, 50.0]]; + let data = vectorcraft_format::base64_encode(&f); + // Place: one clip group of the named path, not the other, at 100 % (72 ppi: a pixel a point). + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + let r = s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "at": [50, 25]})).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + let (anchors, even_odd, image) = clip_group(&s.doc().unwrap().doc, id); + assert_eq!((anchors, even_odd), (triangle.clone(), true)); + assert_eq!(r["image"], image.0, "{r}"); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(100.0), Some(50.0))); + // Open with layers off, and a flat file opened with layers on, as its merged image. + for params in [json!({"layers": false}), json!({})] { + s.execute("document.open", &merge(json!({"name": "cut.psd", "dataBase64": data}), params.clone())).unwrap(); + let d = &s.doc().unwrap().doc; + let top = d.layers[0].children().unwrap(); + assert_eq!(top.len(), 1, "{params}"); + assert_eq!(top[0].name.as_deref(), Some("cut.psd"), "{params}"); + let group = top[0].id; + let (anchors, even_odd, _) = clip_group(d, group); + assert_eq!((anchors, even_odd), (triangle.clone(), true), "{params}"); + // The corner outside the triangle draws nothing; its middle draws red. + let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8(); + assert_eq!((png.get_pixel(2, 2).0[3], png.get_pixel(50, 40).0), (0, [255, 0, 0, 255]), "{params}"); + // The clipping path is an ordinary path: releasing the clipping mask leaves the image whole. + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("object.clippingMask.release", &json!({})).unwrap(); + let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8(); + assert_eq!(png.get_pixel(2, 2).0, [255, 0, 0, 255], "{params}"); + } +} + +/// Replace keeps a clipped PSD file's scale, as it keeps an image's. +#[test] +fn replacing_a_clipped_psd_keeps_its_scale() { + let data = vectorcraft_format::base64_encode(&red_psd(&triangle_resources())); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "at": [200, 200]})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + let r = s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "replace": true})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}"); + let (anchors, _, _) = clip_group(&s.doc().unwrap().doc, NodeId(r["ids"][0].as_u64().unwrap())); + assert_eq!(anchors, [[200.0, 150.0], [300.0, 250.0], [100.0, 250.0]]); + // Other art replacing the clip group takes its centre only, as before. + let plain = vectorcraft_format::base64_encode(&red_psd(&[])); + let r = s.execute("file.place", &json!({"name": "plain.psd", "dataBase64": plain, "replace": true})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(100.0), Some(50.0)), "{r}"); +} + +#[test] +fn a_fill_that_starts_with_all_pixels_puts_the_image_around_the_path() { + let hole = saved_path(true, &[(true, &[[0.25, 0.25], [0.75, 0.25], [0.75, 0.75], [0.25, 0.75]])]); + let f = red_psd(&[resource(2000, b"Hole", &hole), clipping_name(b"Hole")].concat()); + let path = clipping_path(&f).unwrap(); + let starts: Vec<_> = path.subpaths.iter().map(|s| (s.anchors[0].p.x, s.anchors[0].p.y, s.closed)).collect(); + assert_eq!(starts, [(0.0, 0.0, true), (25.0, 12.5, true)], "the image's edges, then the hole"); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "hole.psd", "dataBase64": vectorcraft_format::base64_encode(&f), "layers": false})).unwrap(); + let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8(); + assert_eq!((png.get_pixel(2, 2).0, png.get_pixel(50, 25).0[3]), ([255, 0, 0, 255], 0)); +} + +#[test] +fn files_naming_no_clipping_path_or_a_missing_one_place_a_plain_image() { + let other = saved_path(false, &[(true, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])]); + let empty = saved_path(false, &[]); + for (resources, what) in [ + (resource(2000, b"Other", &other), "saved paths, none named"), + ([resource(2000, b"Other", &other), clipping_name(b"Gone")].concat(), "a name no path has"), + ([resource(2000, b"Empty", &empty), clipping_name(b"Empty")].concat(), "a path with no knots"), + ([resource(3000, b"Other", &other), clipping_name(b"Other")].concat(), "a resource outside the saved paths"), + ([resource(2000, b"Other", &other), [&b"MeSa"[..], &clipping_name(b"Other")[4..]].concat()].concat(), "a block not signed 8BIM"), + (vec![], "no resources"), + ] { + let f = red_psd(&resources); + assert!(clipping_path(&f).is_none(), "{what}"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("file.place", &json!({"name": "a.psd", "dataBase64": vectorcraft_format::base64_encode(&f)})).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + assert!(matches!(s.doc().unwrap().doc.node(id).map(|n| &n.kind), Some(NodeKind::Image(_))), "{what}"); + assert!(r.get("image").is_none(), "{what}: {r}"); + } +} + +#[test] +fn damaged_clipping_paths_are_read_without_a_panic() { + let good = red_psd(&triangle_resources()); + assert!(clipping_path(&good).is_some()); + // The header, the empty colour mode data, then the resources' length and the resources. + let start = 26 + 4; + let end = start + 4 + triangle_resources().len(); + for n in 0..end + 2 { + let _ = clipping_path(&good[..n]); + } + for i in start..end { + for v in [0, 1, 0x7f, 0x80, 0xff] { + let mut f = good.clone(); + f[i] = v; + let _ = clipping_path(&f); + } + } + // Knots before any length record, and more knots than a length record counts, are left out. + let mut extra = saved_path(false, &[(true, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])]); + extra[26 * 2 + 3] = 2; + let mut stray = 2u16.to_be_bytes().to_vec(); + stray.resize(26, 0); + extra.splice(26..26, stray); + let path = clipping_path(&red_psd(&[resource(2000, b"P", &extra), clipping_name(b"P")].concat())).unwrap(); + assert_eq!(path.subpaths.iter().map(|s| s.anchors.len()).collect::>(), [2]); +} + #[test] fn damaged_and_unsupported_files_are_refused_without_a_panic() { let good = psd(2, 4, (3, 2), 8, 3, &[], &[], &layer_count(-1, true), &raw(&[&[1; 6], &[2; 6], &[3; 6], &[4; 6]])); diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index dc95f925b6..8c9a19cb8d 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -113,7 +113,9 @@ pub fn specs() -> Vec { /// A file's art at its natural size, before it joins a document. enum Art { - Image(RasterImage), + /// An image, with the clipping path its file names, in its pixel space (a PSD or PSB file's, + /// [`fileio::psdread::clipping_path`]). + Image(RasterImage, Option), /// A text file's, an RTF file's or a Word document's text, placed as area type filling the /// natural box. Text(text::Story), @@ -241,7 +243,8 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result(nodes: impl Iterator>) -> Vec { /// The art as one object of `d` (its resources added), at its natural size and position. fn build(d: &mut Document, l: Loaded, link: Option) -> Node { let mut node = match l.art { - Art::Image(img) => { + Art::Image(img, clip) => { let (sx, sy) = pt_per_px(img.ppi); super::links::store_image(d, &img.key, img.blob, link.is_some()); let im = ImageObject { @@ -423,7 +426,8 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { placement: Default::default(), pixels: None, }; - Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + fileio::file_clipped(d, image, clip) } Art::Dcs(a) => { let a = *a; @@ -485,9 +489,23 @@ fn at_param(p: &Value) -> Result> { /// The transform that puts new art with the natural box `natural` where `old` is: an image's scale /// and rotation relative to its 100% size (its resolution's) and its centre are kept (with -/// `image_xf`); other objects keep their centre. -fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool) -> Affine { - if image_xf && let NodeKind::Image(im) = &old.kind { +/// `image_xf`), also for an image alone in a clip group when the new art is an image clipped by +/// its file's clipping path (`clipped`); other objects keep their centre. +fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool, clipped: bool) -> Affine { + // An image alone in a clip group (as a PSD or PSB file's clipping path places it, + // [`fileio::file_clipped`]) is measured as the image when the new art brings its own clip + // group; a DCS file's plates are not. + let image = match &old.kind { + NodeKind::Group { children, clip: true } if clipped => match &children[..] { + [path, image] if matches!(path.kind, NodeKind::Path { clipping: true, .. }) => match &image.kind { + NodeKind::Image(im) if d.images.get(&im.key).is_none_or(|b| b.separated().is_none()) => image, + _ => old, + }, + _ => old, + }, + _ => old, + }; + if image_xf && let NodeKind::Image(im) = &image.kind { let (sx, sy) = pt_per_px(d.images.get(&im.key).and_then(|b| fileio::ppi::resolution(&b.bytes))); let full = Affine::scale_non_uniform(sx, sy); let centre = full * Point::new(im.width as f64 / 2.0, im.height as f64 / 2.0); @@ -541,7 +559,7 @@ impl PlaceJob { let mut loaded = load(&self.params, PLACE, self.target, true)?; let linked = loaded.link.is_some() && bool_or(&self.params, "link", true); match &mut loaded.art { - Art::Image(img) => { + Art::Image(img, _) => { let unread = super::links::kept_unread(&img.blob.bytes); img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?; } @@ -657,7 +675,7 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { } let natural = loaded.natural; let m = match (old_node, rect) { - (Some(o), _) => replace_xf(&st.doc, o, natural, !is_text), + (Some(o), _) => replace_xf(&st.doc, o, natural, !is_text, matches!(loaded.art, Art::Image(_, Some(_)))), (None, Some(r)) => fit(natural, r), // Its artboard's bottom-left corner on the target's. (None, None) if let (Some(src), Some(to)) = (loaded.board, target) => Affine::translate((to.x0 - src.x0, to.y1 - src.y1)), @@ -671,7 +689,7 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { let link = loaded.link.take().filter(|_| bool_or(p, "link", true)); let linked = link.is_some(); let (name, format, mut warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings)); - let dcs_clipped = matches!(&loaded.art, Art::Dcs(a) if a.clip.is_some()); + let file_clipped = matches!(&loaded.art, Art::Dcs(a) if a.clip.is_some()) || matches!(&loaded.art, Art::Image(_, Some(_))); if let Art::Dcs(a) = &loaded.art { warnings.extend(dcs::swatch_notes(&st.doc, &a.spots)); } @@ -697,8 +715,9 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { Ok((id, size)) })?; let mut out = json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }); - // A DCS file clipped by its clipping path: its image, which the Links commands take. - if dcs_clipped { + // A DCS, PSD or PSB file clipped by its clipping path: its image, which the Links commands + // take. + if file_clipped { let image = s.doc()?.doc.node(id).and_then(|n| match &n.kind { NodeKind::Group { children, clip: true } => children.get(1).filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.id), _ => None, @@ -810,7 +829,7 @@ fn read_queue(doc: u64, p: Value, queued: Vec) -> Result { let checked = load(&q, QUEUE, None, false).and_then(|mut l| { let linked = l.link.is_some() && bool_or(&q, "link", true); match &mut l.art { - Art::Image(img) => { + Art::Image(img, _) => { let unread = super::links::kept_unread(&img.blob.bytes); let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable }; img.blob = check(img.blob.clone(), unread, linked)?; @@ -932,7 +951,7 @@ pub fn read_info(p: &Value) -> Result { "height": natural.height(), "warnings": std::mem::take(&mut l.warnings), }); - if let Art::Image(img) = &l.art { + if let Art::Image(img, _) = &l.art { let (x, y) = img.ppi.unwrap_or((72.0, 72.0)); out["pixelWidth"] = json!(img.width); out["pixelHeight"] = json!(img.height); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 7e613460cd..ec98d9f5d3 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1831,6 +1831,80 @@ proptest! { } } +/// A 4 × 2 pixel RGB PSD file whose clipping path resource (2999) names its saved path "P" +/// (resource 2000): a closed subpath of a linked and two unlinked knots, an open subpath, and the +/// initial fill record saying the fill starts with all pixels. +fn clipped_psd() -> Vec { + let record = |selector: u16, body: &[i32]| { + let mut r = selector.to_be_bytes().to_vec(); + r.extend(body.iter().flat_map(|v| v.to_be_bytes())); + r.resize(26, 0); + r + }; + let one = 1 << 24; + let mut path = record(6, &[]); + path.extend(record(8, &[1 << 16])); + path.extend(record(0, &[3 << 16])); + path.extend(record(1, &[0, 0, 0, one / 2, 0, one])); + path.extend(record(2, &[one, one, one, one, one, one])); + path.extend(record(2, &[one, 0, one, 0, one, 0])); + path.extend(record(3, &[2 << 16])); + path.extend(record(5, &[-one, -one, 0, 0, 2 * one, 2 * one])); + path.extend(record(4, &[one / 4, one / 4, one / 4, one / 4, one / 4, one / 4])); + let mut res = b"8BIM\x07\xd0\x01P".to_vec(); + res.extend((path.len() as u32).to_be_bytes()); + res.extend(path); + res.extend(b"8BIM\x0b\xb7\0\0"); + res.extend(8u32.to_be_bytes()); + res.extend([1, b'P', 0, 0, 0, 0, 0, 0]); + let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x04\0\x08\0\x03".to_vec(); + f.extend([0; 4]); + f.extend((res.len() as u32).to_be_bytes()); + f.extend(res); + f.extend([0; 4]); + f.extend([0, 0]); + f.extend((0..24u8).map(|v| v * 10)); + f +} + +#[test] +fn the_clipped_psd_sample_places_in_a_clip_group() { + let path = vectorcraft_engine::cmd::fileio::psdread::clipping_path(&clipped_psd()).unwrap(); + assert_eq!(path.subpaths.iter().map(|s| (s.anchors.len(), s.closed)).collect::>(), [(4, true), (3, true), (2, false)]); + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = s.execute("file.place", &json!({"name": "c.psd", "dataBase64": vectorcraft_format::base64_encode(&clipped_psd())})).unwrap(); + assert!(r["image"].is_u64(), "{r}"); +} + +proptest! { + #![proptest_config(config())] + + /// Mutated copies of a PSD file with a clipping path (its resources: the path records, their + /// selectors, counts and points, and the names) never crash reading the path, placing the file + /// or opening its merged image. + #[test] + fn mutated_psd_clipping_paths_never_panic(cut in 0usize..1000, edits in prop::collection::vec((0usize..300, any::()), 0..12)) { + let mut bytes = clipped_psd(); + let len = bytes.len(); + for (at, b) in edits { + if let Some(x) = bytes.get_mut(at % len) { + *x = b; + } + } + bytes.truncate((cut % (len + 1)).max(8)); + let r = catch_quiet(|| { + let _ = vectorcraft_engine::cmd::fileio::psdread::clipping_path(&bytes); + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let p = json!({"name": "c.psd", "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8}); + let _ = no_panic(s.execute("file.place", &p)); + let _ = no_panic(s.execute("document.open", &json!({"name": "c.psd", "dataBase64": vectorcraft_format::base64_encode(&bytes), "layers": false}))); + }); + prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); + } +} + /// 1-bit TIFFs compressed with CCITT Group 3: modified Huffman (Compression 2) in three strips, T.4 /// (Compression 3) with two-dimensional rows, fill bits, six EOLs at the end and each byte's lowest /// bit first, and T.4 two-dimensional rows in 16 × 16 tiles. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0e0d2ced4e..8be5ad7fee 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -290,7 +290,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | -| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | +| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; the merged image comes in clipped by the file's clipping path; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | | PSB | open | yes (layers as layers, or the merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | diff --git a/docs/mcp.md b/docs/mcp.md index fc03d070b6..68061a2720 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8176,6 +8176,16 @@ flattened picture every PSD file carries: Bitmap, Grayscale, Duotone, Indexed, R A document with transparency keeps it. `file.place {path}` links the file like any image (`links.update` reads it again when it changes); Multichannel files are refused. +A PSD or PSB file that names a clipping path (the saved path its clipping path resource names, in the +[published PSD file format specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/)) places, and +opens as its merged image (`layers: false`, a file with no layers, or a file whose layers can't be read), inside a clip group whose clipping +path is that path, under the even-odd rule, so the image shows only inside it. The clipping path is an ordinary path +that can be edited or released. `file.place` answers `image`, the id of the image in the clip group. The +[import options help page](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) +says that a file imported as one flattened image keeps the document's clipping path when it has one. `links.update` +and `links.relink` read the file's pixels again and leave the clip group and its clipping path as they are. Files that name no clipping path, or name a path +they don't have, come in as a plain image. + `document.open` converts a PSD or PSB file's layers to objects. In [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html), the option that converts layers to objects keeps as much of the layer structure as it can, merges the layers of a From d0f1346f872c4c8915838e9d21d04303dad44902 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 16:20:16 -0800 Subject: [PATCH 2/3] Docs: record the PSD clipping path in the PSB row and the evidence list The PSB row of docs/file-format-parity.md says the merged image comes in clipped by the file's clipping path, as the PSD row does, and docs/target-app-parity.md's evidence list gets a line for it. --- docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8be5ad7fee..b42c74c402 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -291,7 +291,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; the merged image comes in clipped by the file's clipping path; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | -| PSB | open | yes (layers as layers, or the merged image) | no | | +| PSB | open | yes (layers as layers, or the merged image; the merged image comes in clipped by the file's clipping path) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; container Exif resolution sizes opened and placed images; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0a1f7c81b8..f8f5e5d75a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1797,6 +1797,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - BMP files named `.dib` or `.rle` (run-length encoded bitmaps included), Photoshop files named `.pdd` and PNG files named `.pns` open and place, linked or embedded, as BMP, PSD and PNG files, in any letter case: the Open and Place dialogs list them with their format, the Links panel names their format, and Edit Original opens them. Exports write only `.bmp`, `.psd` and `.png` (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/cmd/fileio/tests_psdread.rs`, `crates/engine/src/tests/links.rs`). - File › Open converts a PSD or PSB file's layers to objects, keeping the layer structure, merging clipped layers into one layer and leaving out hidden layers that would need a merge, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for the option that converts layers to objects; hidden layers come in with `hiddenLayers`, as Import Hidden Layers brings them in, and `layers: false` opens the merged image. VectorCraft's own rules: each layer becomes a layer holding its pixels with its name, visibility, opacity and blend mode, groups become layers of sublayers, layers in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, layer masks become opacity masks, fill opacity multiplies the opacity (in a layer that others are clipped to, it fades only that layer's own pixels), and adjustment layers, layer effects, knockouts and vector masks aren't applied and are counted in the warnings. 8-, 16- and 32-bit files, PSB, raw, PackBits and zipped channels. There is no PSD import options dialog yet (`crates/engine/src/cmd/fileio/tests_psdlayers.rs`). + - A PSD or PSB file's clipping path, the saved path that image resource 2999 names in the [PSD file format specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), clips its merged image on File › Place and on File › Open of the merged image, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for a file imported as one flattened image. The image comes in a clip group whose clipping path has no paint and uses the even-odd rule, and the path can be edited or released. `links.update` and `links.relink` keep the clip group, and files that name no clipping path come in as before (`crates/engine/src/cmd/fileio/tests_psdread.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). From 8eded9679222d6cfd300b38641c512d22a5e52ac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 16:39:17 -0800 Subject: [PATCH 3/3] PSD: transform a file's clipping path in place instead of copying it file_clipped owns the path psdread::clipping_path reads, so it moves it to the image's place without making a second copy of every anchor. --- crates/engine/src/cmd/fileio/load.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index e6eab02ad6..7482ed1020 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -1074,8 +1074,8 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result { /// space (a PSD or PSB file's, [`psdread::clipping_path`]): a clip group of that path (even-odd, /// with no paint) and the image. Without one the image stays as it is. pub(crate) fn file_clipped(d: &mut Document, image: Node, clip: Option) -> Node { - let (Some(path), NodeKind::Image(im)) = (clip, &image.kind) else { return image }; - let path = path.transformed(im.xf); + let (Some(mut path), NodeKind::Image(im)) = (clip, &image.kind) else { return image }; + path.transform(im.xf); let none = vectorcraft_color::Paint::None; let mut clip = Node::path(d.alloc_id(), path, vectorcraft_doc::Appearance::basic(none.clone(), none, 0.0)); if let NodeKind::Path { clipping, rule, .. } = &mut clip.kind {