mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
Fix #918: PSD and PSB files open and place as their merged image
File › Place and File › Open read PSD and PSB files: the merged image every such document carries after its layers, which is what a linked PSD shows. A placed PSD links like any image, so the Links panel updates and relinks it, and it is sized by its ResolutionInfo resource. The reader is written from the public file format specification. It reads Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK (through the colour settings' CMYK) and Lab, at 1, 8, 16 and 32 bits per channel (32-bit values are linear), raw, PackBits or zipped with or without prediction. A document with transparency stores its merged image on white with the alpha in the first extra channel. It comes back with straight colours, also for 16- and 32-bit files, which say so in their Lr16/Lr32 block. Multichannel files are refused with a message. The file is untrusted: every read is bounds-checked and the image's size is checked against the decode budget before anything is allocated. PSB is a read-only format of its own, so exports never write a PSD under a .psb name. The desktop packaging still doesn't associate either extension with VectorCraft (UNASSOCIATED_EXTS): they belong to raster editors. Layers (import options) are left for later. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
be51213f0d
commit
0eb4ce5e98
@@ -8,7 +8,7 @@ use vectorcraft_geom::Affine;
|
||||
|
||||
use super::super::*;
|
||||
use super::pdfimport::LoadOptions;
|
||||
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, read_file};
|
||||
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file};
|
||||
use crate::EngineError;
|
||||
|
||||
/// A file read into a document, with its format and non-fatal import notes.
|
||||
@@ -76,6 +76,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
if let Some(kind) = vectorcraft_metafile::sniff(bytes) {
|
||||
return format(kind.id());
|
||||
}
|
||||
if psdread::is_psd(bytes) {
|
||||
return format(if psdread::is_psb(bytes) { "psb" } else { "psd" });
|
||||
}
|
||||
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
|
||||
return Some(f);
|
||||
}
|
||||
@@ -355,37 +358,50 @@ const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
|
||||
/// the decoder can't read, become RGBA in the active colour settings' CMYK.
|
||||
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
|
||||
/// documents, whose merged image is read ([`psdread`]).
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let ppi = super::ppi::resolution(bytes);
|
||||
if psdread::is_psd(bytes) {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
|
||||
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
|
||||
let ppi = super::ppi::resolution(bytes);
|
||||
let cmyk = || ImageBlob::new(f.mime, bytes.to_vec()).cmyk().is_some();
|
||||
let (bytes, mime, (width, height)) = if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
|
||||
(bytes.to_vec(), f.mime, reader.into_dimensions().map_err(err)?)
|
||||
} else {
|
||||
let cmyka = || {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
vectorcraft_doc::cmyk::cmyka_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let img = match (f.id == "tiff").then(cmyka).flatten() {
|
||||
Some(img) => img,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
};
|
||||
let size = img.dimensions();
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
|
||||
// The stored PNG keeps the file's resolution.
|
||||
let png = match ppi {
|
||||
Some(r) => super::ppi::with_png_resolution(&png, r),
|
||||
None => png,
|
||||
};
|
||||
(png, "image/png", size)
|
||||
if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
|
||||
let (width, height) = reader.into_dimensions().map_err(err)?;
|
||||
return stored(bytes.to_vec(), f.mime, (width, height), ppi);
|
||||
}
|
||||
let cmyka = || {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
vectorcraft_doc::cmyk::cmyka_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let img = match (f.id == "tiff").then(cmyka).flatten() {
|
||||
Some(img) => img,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
};
|
||||
as_png(img, ppi)
|
||||
}
|
||||
|
||||
/// `img` stored as a PNG that keeps the file's resolution.
|
||||
fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
|
||||
let size = img.dimensions();
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
|
||||
let png = match ppi {
|
||||
Some(r) => super::ppi::with_png_resolution(&png, r),
|
||||
None => png,
|
||||
};
|
||||
stored(png, "image/png", size, ppi)
|
||||
}
|
||||
|
||||
/// An image's encoded `bytes`, `width` × `height` pixels, ready to embed.
|
||||
fn stored(bytes: Vec<u8>, mime: &'static str, (width, height): (u32, u32), ppi: Option<(f64, f64)>) -> Result<RasterImage> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(err("the image is empty"));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub mod pdf;
|
||||
mod pdfimport;
|
||||
pub mod pngtext;
|
||||
pub mod ppi;
|
||||
pub mod psdread;
|
||||
mod save;
|
||||
mod screens;
|
||||
mod svg;
|
||||
@@ -558,10 +559,12 @@ pub const FORMATS: &[Format] = &[
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
|
||||
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: false, write: true, raster: true, options: PSD_OPTIONS },
|
||||
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
|
||||
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
|
||||
// vector app for a raster editor's documents.
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
|
||||
// Photoshop's large document format: read like a PSD (its merged image), never written.
|
||||
reader("psb", "PSB", &["psb"], "image/x-psb", true),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
@@ -581,6 +584,8 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
"dxf",
|
||||
"emf",
|
||||
@@ -591,6 +596,11 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"afpub",
|
||||
];
|
||||
|
||||
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
|
||||
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
|
||||
/// them (File › Open and Place still read them).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb"];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
pub fn export_extensions() -> Vec<&'static str> {
|
||||
@@ -623,6 +633,8 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
"dxf",
|
||||
"emf",
|
||||
@@ -979,6 +991,8 @@ mod tests_tiffbmp;
|
||||
mod tests_pdfx;
|
||||
#[cfg(test)]
|
||||
mod tests_psd;
|
||||
#[cfg(test)]
|
||||
mod tests_psdread;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_epsimport;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and
|
||||
//! WebP EXIF resolution tags, BMP pixels per metre. Place sizes an image by it (72 ppi when the file
|
||||
//! has none); images converted to PNG on import keep it as a `pHYs` chunk.
|
||||
//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo. Place
|
||||
//! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it
|
||||
//! as a `pHYs` chunk.
|
||||
|
||||
const INCH_M: f64 = 0.0254;
|
||||
const INCH_CM: f64 = 2.54;
|
||||
@@ -18,6 +19,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
webp(bytes)
|
||||
} else if bytes.starts_with(b"BM") {
|
||||
bmp(bytes)
|
||||
} else if super::psdread::is_psd(bytes) {
|
||||
super::psdread::resolution(bytes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Reading Photoshop documents (PSD, and PSB, the large document format) for File › Open and
|
||||
//! 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/>). Layers aren't read.
|
||||
//!
|
||||
//! 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,
|
||||
//! PackBits-compressed or zipped. The merged image of a document with transparency is stored on
|
||||
//! white with its alpha in the first extra channel: it comes back with straight colours. The file
|
||||
//! is untrusted: every read is checked and the image's size is capped before anything is
|
||||
//! allocated.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use vectorcraft_color::cms::{Lab, lab::lab_to_srgb};
|
||||
|
||||
/// Photoshop's colour modes (the header's numbers), as far as the merged image is concerned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
Bitmap,
|
||||
Gray,
|
||||
Indexed,
|
||||
Rgb,
|
||||
Cmyk,
|
||||
Lab,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The channels holding colour (an alpha channel may follow them).
|
||||
fn colors(self) -> usize {
|
||||
match self {
|
||||
Mode::Bitmap | Mode::Gray | Mode::Indexed => 1,
|
||||
Mode::Rgb | Mode::Lab => 3,
|
||||
Mode::Cmyk => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `bytes` a Photoshop document (PSD or PSB)?
|
||||
pub fn is_psd(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"8BPS") && matches!(bytes.get(4..6), Some([0, 1] | [0, 2]))
|
||||
}
|
||||
|
||||
/// Is `bytes` a PSB, Photoshop's large document format?
|
||||
pub fn is_psb(bytes: &[u8]) -> bool {
|
||||
is_psd(bytes) && bytes.get(4..6) == Some(&[0, 2])
|
||||
}
|
||||
|
||||
const TRUNCATED: &str = "the PSD file is cut short";
|
||||
const DAMAGED: &str = "the PSD file's header is damaged";
|
||||
|
||||
/// Big-endian reads through `b`, each checked against its end.
|
||||
struct Reader<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn new(b: &'a [u8]) -> Self {
|
||||
Self { b, at: 0 }
|
||||
}
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
|
||||
let s = self.at.checked_add(n).and_then(|end| self.b.get(self.at..end)).ok_or(TRUNCATED)?;
|
||||
self.at += n;
|
||||
Ok(s)
|
||||
}
|
||||
fn array<const N: usize>(&mut self) -> Result<[u8; N], String> {
|
||||
self.take(N)?.try_into().map_err(|_| TRUNCATED.to_string())
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8, String> {
|
||||
self.array::<1>().map(|[v]| v)
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, String> {
|
||||
self.array().map(u16::from_be_bytes)
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, String> {
|
||||
self.array().map(u32::from_be_bytes)
|
||||
}
|
||||
/// A section length: 4 bytes in a PSD, 8 in a PSB.
|
||||
fn length(&mut self, psb: bool) -> Result<usize, String> {
|
||||
let n = if psb { self.array().map(u64::from_be_bytes)? } else { u64::from(self.u32()?) };
|
||||
usize::try_from(n).map_err(|_| TRUNCATED.to_string())
|
||||
}
|
||||
/// A section: its length, then that many bytes.
|
||||
fn section(&mut self, psb: bool) -> Result<&'a [u8], String> {
|
||||
let n = self.length(psb)?;
|
||||
self.take(n)
|
||||
}
|
||||
fn rest(&self) -> &'a [u8] {
|
||||
self.b.get(self.at..).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed-size header and the sections before the image data.
|
||||
struct Header<'a> {
|
||||
psb: bool,
|
||||
channels: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
depth: u16,
|
||||
mode: Mode,
|
||||
/// Colour mode data: an indexed image's palette (256 reds, then greens, then blues).
|
||||
palette: &'a [u8],
|
||||
/// The image resources.
|
||||
resources: &'a [u8],
|
||||
/// The layer and mask information.
|
||||
layers: &'a [u8],
|
||||
}
|
||||
|
||||
fn header<'a>(r: &mut Reader<'a>) -> Result<Header<'a>, String> {
|
||||
if !is_psd(r.b) {
|
||||
return Err("not a PSD file".into());
|
||||
}
|
||||
r.take(4)?;
|
||||
let psb = r.u16()? == 2;
|
||||
r.take(6)?;
|
||||
let channels = usize::from(r.u16()?);
|
||||
let height = r.u32()?;
|
||||
let width = r.u32()?;
|
||||
let depth = r.u16()?;
|
||||
let mode = match r.u16()? {
|
||||
0 => Mode::Bitmap,
|
||||
// Duotone documents keep their grayscale data in the merged image.
|
||||
1 | 8 => Mode::Gray,
|
||||
2 => Mode::Indexed,
|
||||
3 => Mode::Rgb,
|
||||
4 => Mode::Cmyk,
|
||||
9 => Mode::Lab,
|
||||
7 => return Err("Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode".into()),
|
||||
m => return Err(format!("the PSD file has an unknown colour mode ({m})")),
|
||||
};
|
||||
let max_side = if psb { 300_000 } else { 30_000 };
|
||||
if !(1..=56).contains(&channels) || channels < mode.colors() || !(1..=max_side).contains(&width) || !(1..=max_side).contains(&height) {
|
||||
return Err(DAMAGED.into());
|
||||
}
|
||||
let depth_ok = match mode {
|
||||
Mode::Bitmap => depth == 1,
|
||||
Mode::Indexed => depth == 8,
|
||||
Mode::Cmyk | Mode::Lab => matches!(depth, 8 | 16),
|
||||
Mode::Gray | Mode::Rgb => matches!(depth, 8 | 16 | 32),
|
||||
};
|
||||
if !depth_ok {
|
||||
return Err(format!("{depth}-bit PSD files in this colour mode can't be read"));
|
||||
}
|
||||
let palette = r.section(false)?;
|
||||
let resources = r.section(false)?;
|
||||
let layers = r.section(psb)?;
|
||||
Ok(Header { psb, channels, width, height, depth, mode, palette, resources, layers })
|
||||
}
|
||||
|
||||
/// The resolution a Photoshop document declares (pixels per inch, across and down), from its
|
||||
/// ResolutionInfo resource (1005).
|
||||
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 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 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()?));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Does the merged image keep its transparency in the first extra channel? Yes when the layer
|
||||
/// count is negative: in the layer info, or for 16- and 32-bit documents (whose layer info is
|
||||
/// empty) in their `Lr16` / `Lr32` block.
|
||||
fn merged_alpha(layers: &[u8], psb: bool) -> bool {
|
||||
let count_negative = |info: &[u8]| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0);
|
||||
let mut r = Reader::new(layers);
|
||||
let Ok(info) = r.section(psb) else { return false };
|
||||
if !info.is_empty() {
|
||||
return count_negative(info);
|
||||
}
|
||||
// The global layer mask info, then tagged blocks: signature, key, length (8 bytes for the
|
||||
// layer blocks of a PSB), data padded to 4 bytes.
|
||||
if r.section(false).is_err() {
|
||||
return false;
|
||||
}
|
||||
while r.rest().len() >= 12 {
|
||||
let (Ok(_), Ok(key)) = (r.take(4), r.array::<4>()) else { return false };
|
||||
let layer_block = matches!(&key, b"Lr16" | b"Lr32" | b"Layr");
|
||||
let long = layer_block || matches!(&key, b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD");
|
||||
let Ok(data) = r.section(psb && long) else {
|
||||
return false;
|
||||
};
|
||||
if layer_block {
|
||||
return count_negative(data);
|
||||
}
|
||||
if r.take((4 - data.len() % 4) % 4).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The most bytes one channel row may take (a PSB at 32 bits).
|
||||
fn row_bytes(width: usize, depth: u16) -> usize {
|
||||
if depth == 1 { width.div_ceil(8) } else { width * usize::from(depth / 8) }
|
||||
}
|
||||
|
||||
/// Undo PackBits: `src` into `dst` (a row; a short or overlong run leaves the rest as it is).
|
||||
fn unpack_bits(mut src: &[u8], dst: &mut [u8]) {
|
||||
let mut at = 0;
|
||||
while let Some((&n, rest)) = src.split_first() {
|
||||
src = rest;
|
||||
let n = n as i8;
|
||||
if n >= 0 {
|
||||
let len = usize::from(n.unsigned_abs()) + 1;
|
||||
let (run, rest) = src.split_at(len.min(src.len()));
|
||||
src = rest;
|
||||
for &b in run {
|
||||
if let Some(d) = dst.get_mut(at) {
|
||||
*d = b;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
} else if n != -128 {
|
||||
let Some((&b, rest)) = src.split_first() else { break };
|
||||
src = rest;
|
||||
let len = usize::from(n.unsigned_abs()) + 1;
|
||||
for d in dst.iter_mut().skip(at).take(len) {
|
||||
*d = b;
|
||||
}
|
||||
at += len;
|
||||
}
|
||||
if at >= dst.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `channels` channels of the image data (after its compression method), each as
|
||||
/// `height` rows of `row` bytes, one after the other.
|
||||
fn channel_data(data: &[u8], h: &Header<'_>, channels: usize, row: usize) -> Result<Vec<u8>, String> {
|
||||
let (rows, all) = (h.height as usize, h.channels);
|
||||
let mut r = Reader::new(data);
|
||||
let method = r.u16()?;
|
||||
let wanted = channels * rows * row;
|
||||
match method {
|
||||
0 => r.take(wanted).map(<[u8]>::to_vec),
|
||||
1 => {
|
||||
// Every row's byte count (4 bytes each in a PSB), channel by channel: those of the
|
||||
// channels read, then the others'.
|
||||
let size = if h.psb { 4 } else { 2 };
|
||||
let counts = r.take(channels * rows * size)?;
|
||||
r.take((all - channels) * rows * size)?;
|
||||
let counts = counts.chunks_exact(size).map(|c| c.iter().fold(0usize, |n, &b| n << 8 | usize::from(b)));
|
||||
let mut out = vec![0; wanted];
|
||||
for (dst, n) in out.chunks_exact_mut(row).zip(counts) {
|
||||
unpack_bits(r.take(n)?, dst);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
2 | 3 => {
|
||||
let mut out = Vec::new();
|
||||
flate2::read::ZlibDecoder::new(r.rest())
|
||||
.take(wanted as u64)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("the PSD file's image data can't be read: {e}"))?;
|
||||
if out.len() < wanted {
|
||||
return Err(TRUNCATED.into());
|
||||
}
|
||||
if method == 3 {
|
||||
// Each row stores the difference from the sample before it.
|
||||
match h.depth {
|
||||
8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))),
|
||||
16 => out.chunks_exact_mut(row).for_each(|r| {
|
||||
let mut prev = 0u16;
|
||||
for s in r.as_chunks_mut::<2>().0 {
|
||||
prev = prev.wrapping_add(u16::from_be_bytes(*s));
|
||||
*s = prev.to_be_bytes();
|
||||
}
|
||||
}),
|
||||
_ => return Err("zipped 32-bit PSD image data with prediction can't be read".into()),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
m => Err(format!("the PSD file's image data uses an unknown compression ({m})")),
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB's transfer curve, for 32-bit documents, whose values are linear.
|
||||
fn srgb_encode(v: f32) -> f32 {
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
/// Sample `x` of a channel row, 0..1.
|
||||
fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 {
|
||||
match depth {
|
||||
1 => {
|
||||
// Bitmap: a set bit is black.
|
||||
let bit = row.get(x / 8).map_or(0, |b| (b >> (7 - x % 8)) & 1);
|
||||
if bit == 1 { 0.0 } else { 1.0 }
|
||||
}
|
||||
16 => row.get(x * 2..x * 2 + 2).map_or(0.0, |s| f32::from(u16::from_be_bytes([s[0], s[1]])) / 65535.0),
|
||||
32 => {
|
||||
let v = row.get(x * 4..x * 4 + 4).map_or(0.0, |s| f32::from_be_bytes([s[0], s[1], s[2], s[3]]));
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if linear { srgb_encode(v) } else { v }
|
||||
}
|
||||
_ => row.get(x).map_or(0.0, |&b| f32::from(b) / 255.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn byte(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// A Photoshop document's merged image as straight RGBA. CMYK converts through `cmyk` (ink
|
||||
/// amounts 0..1 → sRGB). Fails, without allocating, when the image would need more than
|
||||
/// `max_alloc` bytes.
|
||||
pub fn decode(bytes: &[u8], max_alloc: u64, cmyk: impl Fn([f32; 4]) -> [f32; 3]) -> Result<image::RgbaImage, String> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let h = header(&mut r)?;
|
||||
let colors = h.mode.colors();
|
||||
let alpha = h.channels > colors && merged_alpha(h.layers, h.psb);
|
||||
let channels = colors + usize::from(alpha);
|
||||
let (w, rows) = (h.width as usize, h.height as usize);
|
||||
let row = row_bytes(w, h.depth);
|
||||
// The channels read and the RGBA image made of them.
|
||||
let need = (channels as u64 * row as u64).saturating_add(4 * w as u64).saturating_mul(rows as u64);
|
||||
if need > max_alloc {
|
||||
return Err(format!("the PSD image is too large to read ({} × {} pixels)", h.width, h.height));
|
||||
}
|
||||
let data = channel_data(r.rest(), &h, channels, row)?;
|
||||
let mut out = image::RgbaImage::new(h.width, h.height);
|
||||
let linear = h.depth == 32;
|
||||
for (y, line) in out.rows_mut().enumerate() {
|
||||
let planes: Vec<&[u8]> = (0..channels).map(|c| data.get((c * rows + y) * row..(c * rows + y + 1) * row).unwrap_or_default()).collect();
|
||||
for (x, px) in line.enumerate() {
|
||||
let v = |c: usize| planes.get(c).map_or(0.0, |p| sample(p, x, h.depth, linear && c < colors));
|
||||
let rgb = match h.mode {
|
||||
Mode::Bitmap | Mode::Gray => [v(0); 3],
|
||||
Mode::Indexed => {
|
||||
let i = usize::from(byte(v(0)));
|
||||
[0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0))
|
||||
}
|
||||
Mode::Rgb => [v(0), v(1), v(2)],
|
||||
// Inks are stored as 1 − amount.
|
||||
Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))),
|
||||
Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }),
|
||||
};
|
||||
let a = if alpha { v(colors) } else { 1.0 };
|
||||
// The merged image is stored on white: take the white back out.
|
||||
let straight = |c: f32| if a <= 0.0 { 0.0 } else { (c - (1.0 - a)) / a };
|
||||
let rgb = if alpha { rgb.map(straight) } else { rgb };
|
||||
px.0 = [byte(rgb[0]), byte(rgb[1]), byte(rgb[2]), byte(a)];
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ fn psd_is_listed_with_its_options_and_writes_one_file_per_artboard() {
|
||||
let psd = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "psd").unwrap().clone();
|
||||
assert_eq!(
|
||||
(psd["label"].as_str(), psd["write"].as_bool(), psd["read"].as_bool(), psd["raster"].as_bool()),
|
||||
(Some("PSD"), Some(true), Some(false), Some(true))
|
||||
(Some("PSD"), Some(true), Some(true), Some(true))
|
||||
);
|
||||
for k in ["colorModel", "layers", "maxEditability", "hiddenLayers", "embedIcc", "ppi", "antiAlias", "background"] {
|
||||
assert!(psd["options"].get(k).is_some(), "psd takes {k}");
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! 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.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::psdread::{decode, is_psd, resolution};
|
||||
use super::*;
|
||||
|
||||
/// A Photoshop document built from its parts: `version` 1 (PSD) or 2 (PSB), the header's fields,
|
||||
/// then the colour mode data, resources, layer and mask section and image data as given.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn psd(
|
||||
version: u16,
|
||||
channels: u16,
|
||||
(w, h): (u32, u32),
|
||||
depth: u16,
|
||||
mode: u16,
|
||||
palette: &[u8],
|
||||
resources: &[u8],
|
||||
layers: &[u8],
|
||||
image: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut f = b"8BPS".to_vec();
|
||||
f.extend(version.to_be_bytes());
|
||||
f.extend([0; 6]);
|
||||
f.extend(channels.to_be_bytes());
|
||||
f.extend(h.to_be_bytes());
|
||||
f.extend(w.to_be_bytes());
|
||||
f.extend(depth.to_be_bytes());
|
||||
f.extend(mode.to_be_bytes());
|
||||
for part in [palette, resources] {
|
||||
f.extend((part.len() as u32).to_be_bytes());
|
||||
f.extend(part);
|
||||
}
|
||||
if version == 2 {
|
||||
f.extend((layers.len() as u64).to_be_bytes());
|
||||
} else {
|
||||
f.extend((layers.len() as u32).to_be_bytes());
|
||||
}
|
||||
f.extend(layers);
|
||||
f.extend(image);
|
||||
f
|
||||
}
|
||||
|
||||
/// Raw image data: the compression method (0), then the planes.
|
||||
fn raw(planes: &[&[u8]]) -> Vec<u8> {
|
||||
let mut d = vec![0, 0];
|
||||
planes.iter().for_each(|p| d.extend(*p));
|
||||
d
|
||||
}
|
||||
|
||||
/// A layer and mask section whose layer info holds just the layer `count` (negative: the merged
|
||||
/// image's first extra channel is its transparency).
|
||||
fn layer_count(count: i16, psb: bool) -> Vec<u8> {
|
||||
let mut s = if psb { 2u64.to_be_bytes().to_vec() } else { 2u32.to_be_bytes().to_vec() };
|
||||
s.extend(count.to_be_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Straight sRGB through a naive CMYK conversion (tests don't depend on colour settings).
|
||||
fn naive(c: [f32; 4]) -> [f32; 3] {
|
||||
[0, 1, 2].map(|i| (1.0 - c[i]) * (1.0 - c[3]))
|
||||
}
|
||||
|
||||
fn pixels(f: &[u8]) -> Vec<[u8; 4]> {
|
||||
decode(f, 1 << 30, naive).unwrap().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_colour_mode_and_depth_reads() {
|
||||
// Grayscale, 16 bits.
|
||||
let g = psd(1, 1, (2, 1), 16, 1, &[], &[], &[], &raw(&[&[0, 0, 0xff, 0xff]]));
|
||||
assert!(is_psd(&g));
|
||||
assert_eq!(pixels(&g), [[0, 0, 0, 255], [255, 255, 255, 255]]);
|
||||
// Bitmap: a set bit is black.
|
||||
let b = psd(1, 1, (9, 1), 1, 0, &[], &[], &[], &raw(&[&[0b1000_0000, 0b1000_0000]]));
|
||||
let px = pixels(&b);
|
||||
assert_eq!((px[0], px[1], px[8]), ([0, 0, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255]));
|
||||
// Indexed: 256 reds, greens, blues.
|
||||
let mut palette = vec![0; 768];
|
||||
(palette[1], palette[257], palette[513]) = (10, 20, 30);
|
||||
let i = psd(1, 1, (1, 1), 8, 2, &palette, &[], &[], &raw(&[&[1]]));
|
||||
assert_eq!(pixels(&i), [[10, 20, 30, 255]]);
|
||||
// RGB, 32 bits: linear values, shown through sRGB's curve.
|
||||
let half = 0.5f32.to_be_bytes();
|
||||
let r = psd(1, 3, (1, 1), 32, 3, &[], &[], &[], &raw(&[&half, &half, &1.0f32.to_be_bytes()]));
|
||||
assert_eq!(pixels(&r), [[188, 188, 255, 255]]);
|
||||
// CMYK, inks stored as 255 − amount: 100 % K is black, no ink white.
|
||||
let c = psd(1, 4, (2, 1), 8, 4, &[], &[], &[], &raw(&[&[255, 255], &[255, 255], &[255, 255], &[0, 255]]));
|
||||
assert_eq!(pixels(&c), [[0, 0, 0, 255], [255, 255, 255, 255]]);
|
||||
// Lab: L 100, a 0, b 0 is white.
|
||||
let l = psd(1, 3, (1, 1), 8, 9, &[], &[], &[], &raw(&[&[255], &[128], &[128]]));
|
||||
assert!(pixels(&l)[0].iter().all(|&v| v >= 253), "{:?}", pixels(&l));
|
||||
// Duotone keeps its grayscale data in the merged image.
|
||||
assert_eq!(pixels(&psd(1, 1, (1, 1), 8, 8, &[0; 40], &[], &[], &raw(&[&[64]]))), [[64, 64, 64, 255]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_comes_back_straight_and_extra_channels_without_it_stay_out() {
|
||||
// Half-transparent red, stored on white: (255, 127, 127) with alpha 128.
|
||||
let planes: [&[u8]; 4] = [&[255], &[127], &[127], &[128]];
|
||||
let with = psd(1, 4, (1, 1), 8, 3, &[], &[], &layer_count(-1, false), &raw(&planes));
|
||||
let [r, g, b, a] = pixels(&with)[0];
|
||||
assert!(r == 255 && g <= 1 && b <= 1 && a == 128, "{:?}", [r, g, b, a]);
|
||||
// A flat document's extra channel is a saved selection, not transparency.
|
||||
let flat = psd(1, 4, (1, 1), 8, 3, &[], &[], &[], &raw(&planes));
|
||||
assert_eq!(pixels(&flat), [[255, 127, 127, 255]]);
|
||||
// A 16-bit document says so in its Lr16 block (its layer info is empty).
|
||||
let mut layers = 0u32.to_be_bytes().to_vec();
|
||||
layers.extend(0u32.to_be_bytes());
|
||||
layers.extend(b"8BIMLr16");
|
||||
layers.extend(4u32.to_be_bytes());
|
||||
layers.extend((-1i16).to_be_bytes());
|
||||
layers.extend([0, 0]);
|
||||
let wide: [&[u8]; 4] = [&[0xff, 0xff], &[0x7f, 0x7f], &[0x7f, 0x7f], &[0x80, 0x80]];
|
||||
let deep = psd(1, 4, (1, 1), 16, 3, &[], &[], &layers, &raw(&wide));
|
||||
let [r, g, _, a] = pixels(&deep)[0];
|
||||
assert!(r == 255 && g <= 1 && a == 128, "{:?}", pixels(&deep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packbits_zip_and_psb_files_read() {
|
||||
// PackBits in a PSB: 4-byte row counts, transparency from a negative layer count.
|
||||
let mut rle = vec![0, 1];
|
||||
for _ in 0..4 {
|
||||
rle.extend(2u32.to_be_bytes());
|
||||
}
|
||||
for v in [200u8, 100, 50, 255] {
|
||||
rle.extend([0xfe, v]); // a run of three
|
||||
}
|
||||
let big = psd(2, 4, (3, 1), 8, 3, &[], &[], &layer_count(-1, true), &rle);
|
||||
assert_eq!(pixels(&big), [[200, 100, 50, 255]; 3]);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.open", &json!({"name": "big.psb", "dataBase64": vectorcraft_format::base64_encode(&big)})).unwrap();
|
||||
assert_eq!(r["format"], "psb");
|
||||
// Zipped with prediction, 16 bits: each sample is the difference from the one before.
|
||||
let deltas: Vec<u8> = [0x1000u16, 0x1000, 0x1000].iter().flat_map(|d| d.to_be_bytes()).collect();
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut z, &deltas).unwrap();
|
||||
let mut zip = vec![0, 3];
|
||||
zip.extend(z.finish().unwrap());
|
||||
let px = pixels(&psd(1, 1, (3, 1), 16, 1, &[], &[], &[], &zip));
|
||||
assert_eq!(px.iter().map(|p| p[0]).collect::<Vec<_>>(), [16, 32, 48]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_resolution_resource_sizes_the_placed_image() {
|
||||
// ResolutionInfo (1005): 300 ppi across, 118.11 per cm (300 ppi) down.
|
||||
let mut res = b"8BIM".to_vec();
|
||||
res.extend(0x03edu16.to_be_bytes());
|
||||
res.extend([0, 0]); // an empty name, padded
|
||||
res.extend(16u32.to_be_bytes());
|
||||
res.extend((300u32 << 16).to_be_bytes());
|
||||
res.extend([0, 1, 0, 1]);
|
||||
res.extend(((118.11f64 * 65536.0) as u32).to_be_bytes());
|
||||
res.extend([0, 2, 0, 1]);
|
||||
let f = psd(1, 3, (300, 150), 8, 3, &[], &res, &[], &raw(&[&[9; 45_000], &[9; 45_000], &[9; 45_000]]));
|
||||
let (x, y) = resolution(&f).unwrap();
|
||||
assert!((x - 300.0).abs() < 1e-9 && (y - 300.0).abs() < 0.01, "{x} {y}");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "photo.psd", "dataBase64": vectorcraft_format::base64_encode(&f)})).unwrap();
|
||||
assert!(
|
||||
(r["width"].as_f64().unwrap() - 72.0).abs() < 0.01 && (r["height"].as_f64().unwrap() - 36.0).abs() < 0.01,
|
||||
"an inch by half an inch: {r}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A 60 × 40 pt document: a red square on a transparent page, and a half-transparent blue one.
|
||||
fn art() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 30, "height": 40})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 20, "height": 40})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn exported(s: &mut Session, p: Value) -> Vec<u8> {
|
||||
let r = s.execute("document.export", &p).unwrap();
|
||||
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_exports_open_and_place_again() {
|
||||
let mut s = art();
|
||||
let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8();
|
||||
for (options, what) in [(json!({}), "layered RGB"), (json!({"layers": false}), "flat RGB"), (json!({"colorModel": "gray"}), "grey")] {
|
||||
let f = exported(&mut s, merge(json!({"format": "psd"}), options));
|
||||
let back = decode(&f, 1 << 30, naive).unwrap();
|
||||
assert_eq!(back.dimensions(), png.dimensions(), "{what}");
|
||||
let (red, clear, blue) = (back.get_pixel(10, 20).0, back.get_pixel(35, 20).0, back.get_pixel(50, 20).0);
|
||||
if what == "layered RGB" {
|
||||
assert_eq!((red, clear[3]), ([255, 0, 0, 255], 0), "{what}");
|
||||
assert!(blue[2] >= 250 && blue[0] <= 3 && (blue[3] as i32 - png.get_pixel(50, 20).0[3] as i32).abs() <= 1, "{what}: {blue:?}");
|
||||
} else if what == "flat RGB" {
|
||||
assert_eq!((red, clear), ([255, 0, 0, 255], [255, 255, 255, 255]), "{what}: flat files are on white");
|
||||
} else {
|
||||
assert!(red[0] == red[1] && red[3] == 255, "{what}: {red:?}");
|
||||
}
|
||||
// Open and Place take it by its content, whatever its name.
|
||||
let r = s.execute("document.open", &json!({"name": "back.bin", "dataBase64": vectorcraft_format::base64_encode(&f)})).unwrap();
|
||||
assert_eq!(r["format"], "psd", "{what}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!((d.artboards[0].rect.width() - 60.0).abs() < 1e-6, "{what}");
|
||||
}
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
assert!(r["readable"].as_array().unwrap().iter().any(|v| v == "psd"));
|
||||
assert!(PLACE_EXTS.contains(&"psd") && PLACE_EXTS.contains(&"psb") && OPEN_EXTS.contains(&"psb"));
|
||||
}
|
||||
|
||||
#[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]]));
|
||||
assert!(decode(&good, 1 << 30, naive).is_ok());
|
||||
for n in 0..good.len() {
|
||||
assert!(decode(&good[..n], 1 << 30, naive).is_err(), "cut at {n}");
|
||||
let _ = resolution(&good[..n]);
|
||||
}
|
||||
// Every byte changed in turn: an error or an image, never a panic.
|
||||
for i in 0..good.len() {
|
||||
for v in [0, 1, 0x7f, 0x80, 0xff] {
|
||||
let mut f = good.clone();
|
||||
f[i] = v;
|
||||
let _ = decode(&f, 1 << 30, naive);
|
||||
let _ = resolution(&f);
|
||||
}
|
||||
}
|
||||
// An image too large for the memory allowed is refused before anything is allocated.
|
||||
let huge = psd(2, 3, (300_000, 300_000), 16, 3, &[], &[], &[], &[0, 0]);
|
||||
assert!(decode(&huge, 1 << 30, naive).unwrap_err().contains("too large"));
|
||||
// Multichannel, and depths a mode doesn't have.
|
||||
assert!(decode(&psd(1, 2, (1, 1), 8, 7, &[], &[], &[], &raw(&[&[0], &[0]])), 1 << 30, naive).unwrap_err().contains("Multichannel"));
|
||||
assert!(decode(&psd(1, 4, (1, 1), 32, 4, &[], &[], &[], &[0, 0]), 1 << 30, naive).is_err());
|
||||
// A placed file with a damaged image reports it.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let cut = &good[..good.len() - 3];
|
||||
assert!(s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": vectorcraft_format::base64_encode(cut)})).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user