mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:10:13 +00:00
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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1059,16 +1059,32 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
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<vectorcraft_geom::PathData>) -> 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)
|
||||
|
||||
@@ -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"
|
||||
//! (<https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/>). 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<Header<'a>, 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<f64> {
|
||||
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<Item = (u16, &[u8], &[u8])> {
|
||||
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<f64> {
|
||||
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<u16> = 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<PathData> {
|
||||
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<SubPath> = vec![];
|
||||
let mut all_pixels = false;
|
||||
// The knots the current subpath still takes.
|
||||
let mut left = 0usize;
|
||||
let (records, _) = data.as_chunks::<PATH_RECORD>();
|
||||
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
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
[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<u8> {
|
||||
let record = |selector: u16, body: Vec<u8>| {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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::<Vec<_>>(), [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]]));
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
|
||||
/// 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<vectorcraft_geom::PathData>),
|
||||
/// 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<Rect>, link_pdf: bool) -> Result<Loa
|
||||
let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy);
|
||||
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
|
||||
let warnings = fileio::size_note(&img.blob.bytes).into_iter().collect();
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
|
||||
let clip = fileio::psdread::clipping_path(&src.bytes);
|
||||
return Ok(Loaded { name, format, art: Art::Image(img, clip), natural, warnings, link, board: None });
|
||||
}
|
||||
if pdf {
|
||||
// Linked: placed like a VectorCraft document, read again with the page's box only. A page
|
||||
@@ -411,7 +414,7 @@ fn art_of<'a>(nodes: impl Iterator<Item = &'a Arc<Node>>) -> Vec<Node> {
|
||||
/// 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<LinkInfo>) -> 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<LinkInfo>) -> 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<Option<Point>> {
|
||||
|
||||
/// 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<Value> {
|
||||
}
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value>) -> Result<QueueRead> {
|
||||
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<Value> {
|
||||
"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);
|
||||
|
||||
@@ -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<u8> {
|
||||
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::<Vec<_>>(), [(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::<u8>()), 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.
|
||||
|
||||
@@ -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) | |
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user