mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 03:44:36 +00:00
craft-psd: linked-layer record editing, the thumbnail header and brst (from PhotoCraft's crates/io) (#1054)
* craft-psd: Photoshop PSD/PSB reader and writer, moved from PhotoCraft PhotoCraft's `photocraft-psd` crate (crates/psd at storytold/photocraft 63b641b55e54bf8f5c83efd977fdb16a2c255431) as `craft-psd`: the lossless PSD/PSB model, reader, writer and builder, descriptors, the Photoshop resource files that share its primitives (.abr, .asl, .aco, .act, .ase, .grd, .pat, .atn, .csh, .shc) and the Photoshop blocks of a layered TIFF. The crate had no dependency on any other PhotoCraft crate. Code is unchanged apart from the crate name, craft-libs' rustfmt width, comments that named other PhotoCraft crates, issue references (now `photocraft#N`), and the corpus tests' directory (`CRAFT_PSD_CORPUS` or `corpus/` at the workspace root). Tests, proptests, fuzz targets and fixtures move with it. Part of #778, #786. * craft-psd: engine_data, the text engine document of type layers The EngineData reader and writer from PhotoCraft's photocraft-text (crates/text/src/engine_data.rs at storytold/photocraft 63b641b5): a tree of values that is written back in Photoshop's layout, with strings stored as plain bytes kept as plain bytes. From EffectCraft's effectcraft-psd (crates/psd/src/engine_data.rs at storytold/effectcraft 072e1af9), ported onto that tree: `first_style`, the first style run as a typed `TextStyle` with bounded sizes and spacings, and `minimal`, a one-run document for writers and fixtures. `Value::index` is new. Both apps had their own parser for the same syntax. * craft-psd: engine_data::parse_lossy reads what it can of a damaged document EffectCraft's EngineData reader was best effort: a truncated or damaged document still gave the text and style it held. `parse_lossy` does that on the same parser: a truncated dictionary, array or string keeps what it has, a byte that starts no key is skipped, a token that is no number becomes a name, and garbage gives an empty dictionary. `parse` stays strict and is unchanged in what it accepts. The parser's `value` is split into one function per kind of value, and reads with `get` instead of indexing. * craft-psd: typed views of type layers, smart objects and adjustments Read-only views EffectCraft's effectcraft-psd had over the blocks this crate keeps raw (storytold/effectcraft f334bcf3, crates/psd/src/parse.rs and placed.rs). The blocks stay raw and write back unchanged. - `text`: `TypeLayer` from `TySh`: text, transform (the identity when not finite), bounds, the first style run (read with `engine_data::parse_lossy`) and the text descriptor. `LayerRecord::type_layer`. - `smart`: `SmartObject` from `SoLd`/`SoLE` (id, quad, non-affine transform, size, warp) and `PlLd`; `Warp` with the named styles and the custom quilt mesh; the quad geometry (`map`, `placed_bounds`, `placed_size`, `bake_scale`, `placement`). `LinkedFile` and `linked_files` for `lnk2`/`lnkD`/`lnk3`/`lnkE`, `PsdFile::linked_files` and `PsdFile::linked_data`. `LayerRecord::smart_object`. - `adjust`: `Adjustment` from `nvrt`, `brit`, `CgEd`, `hue2`/`hue `, `levl`, `expA`, and the other adjustment keys by name; `LayerRecord::adjustment`, `solid_color` (`SoCo`) and `effects` (`lfx2`). - `Descriptor`: `object`, `number`, `numbers`, `boolean`, `text`, `enumerated`, `raw_data`, `color`; `Value::as_number`. Linked-file data is a range of the block's data, checked against the record, instead of a range of the whole file. * craft-psd: shared descriptor lookups Descriptor::get_bool, get_number, get_text, get_enum and get_object, used by asl, grd and slices in place of their own copies. Ported from storytold/photocraft#9552 (its crates/psd part). * craft-psd: the typed views use the shared descriptor lookups One set of Descriptor accessors: the get_* lookups from photocraft#9552. numbers, raw_data and color move next to them as get_numbers, get_raw_data and get_color. * craft-psd: linked-layer record editing, the thumbnail header and brst Format code that PhotoCraft keeps in crates/io (linked.rs, psd_thumbnail.rs, blocks.rs at storytold/photocraft f1b5e7d7), moved here without the document mapping: - smart: `file_type`, `encode_embedded_file` (a version-7 liFD record), `linked_records`, `linked_uuids`, `rebuild_linked_block`, `select_linked_records` (exact UTF-8 ids, cut selected records are an error), `contains_linked_record`, `copy_linked_record`. - resources: `Thumbnail` reads the 1036/1033 header, and `Thumbnail::jfif_bytes` writes it as Photoshop does. - tagged: `channel_restrictions` / `channel_restrictions_data` for brst. PhotoCraft's tests for these functions move with them. --------- Co-authored-by: Omar Saidahmed <osaidahm@uwaterloo.ca>
This commit is contained in:
co-authored by
Omar Saidahmed
parent
e98f3d47b3
commit
40ff186888
@@ -149,6 +149,63 @@ impl VersionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// The thumbnail resource (1036; 1033 in Photoshop 4, whose samples are BGR): a 28-byte
|
||||
/// big-endian header and the image data (Adobe's specification, "Thumbnail resource format").
|
||||
/// Photoshop writes format 1 (JFIF), 24 bits per pixel, one plane, with the long side at 160
|
||||
/// pixels or less.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Thumbnail<'a> {
|
||||
/// 1 for JFIF (`kJpegRGB`), 0 for raw RGB (`kRawRGB`).
|
||||
pub format: u32,
|
||||
/// Width in pixels.
|
||||
pub width: u32,
|
||||
/// Height in pixels.
|
||||
pub height: u32,
|
||||
/// Bits per pixel (24).
|
||||
pub bits_per_pixel: u16,
|
||||
/// Planes (1).
|
||||
pub planes: u16,
|
||||
/// The image data after the header: a JFIF stream for format 1.
|
||||
pub data: &'a [u8],
|
||||
}
|
||||
|
||||
impl<'a> Thumbnail<'a> {
|
||||
/// Length of the header in front of the image data.
|
||||
pub const HEADER_LEN: usize = 28;
|
||||
/// Longest side Photoshop gives a thumbnail.
|
||||
pub const MAX_SIDE: u32 = 160;
|
||||
|
||||
/// Reads a thumbnail resource's data. Its sizes are not checked against the image data,
|
||||
/// which a reader decodes on its own.
|
||||
pub fn from_bytes(data: &'a [u8]) -> Result<Self> {
|
||||
let mut r = Reader::new(data);
|
||||
let format = r.u32()?;
|
||||
let (width, height) = (r.u32()?, r.u32()?);
|
||||
// Width bytes, total size and compressed size: derived values a reader recomputes.
|
||||
r.skip(12)?;
|
||||
let (bits_per_pixel, planes) = (r.u16()?, r.u16()?);
|
||||
Ok(Thumbnail { format, width, height, bits_per_pixel, planes, data: r.peek_rest() })
|
||||
}
|
||||
|
||||
/// The data of a JFIF thumbnail resource for a `width`×`height` image: the header as
|
||||
/// Photoshop writes it (width bytes padded to 4, total size, compressed size, 24 bits, one
|
||||
/// plane), then `jfif`. An error when a size does not fit its 32-bit field.
|
||||
pub fn jfif_bytes(width: u32, height: u32, jfif: &[u8]) -> Result<Vec<u8>> {
|
||||
let too_big = || PsdError::LimitExceeded("thumbnail too large for its header");
|
||||
let width_bytes = width.checked_mul(24).map(|v| v.div_ceil(32) * 4).ok_or_else(too_big)?;
|
||||
let total = width_bytes.checked_mul(height).ok_or_else(too_big)?;
|
||||
let compressed = u32::try_from(jfif.len()).map_err(|_| too_big())?;
|
||||
let mut out = Vec::with_capacity(Self::HEADER_LEN + jfif.len());
|
||||
for v in [1, width, height, width_bytes, total, compressed] {
|
||||
out.put_u32(v);
|
||||
}
|
||||
out.put_u16(24);
|
||||
out.put_u16(1);
|
||||
out.put(jfif);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed view of a known resource.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
@@ -421,6 +478,25 @@ mod tests {
|
||||
assert_eq!(ids.len(), usize::from(u16::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_header_follows_the_spec_and_reads_back() {
|
||||
let b = Thumbnail::jfif_bytes(160, 90, &[0xFF, 0xD8, 0xFF, 0xD9]).unwrap();
|
||||
let u = |i: usize| u32::from_be_bytes(b[i..i + 4].try_into().unwrap());
|
||||
assert_eq!((u(0), u(4), u(8), u(12), u(16), u(20)), (1, 160, 90, 480, 480 * 90, 4));
|
||||
assert_eq!(&b[24..28], &[0, 24, 0, 1]);
|
||||
assert_eq!(&b[28..], &[0xFF, 0xD8, 0xFF, 0xD9]);
|
||||
// Width bytes are padded to 4: 3 px × 3 bytes = 9 → 12.
|
||||
let odd = Thumbnail::jfif_bytes(3, 1, &[]).unwrap();
|
||||
assert_eq!(u32::from_be_bytes(odd[12..16].try_into().unwrap()), 12);
|
||||
let t = Thumbnail::from_bytes(&b).unwrap();
|
||||
assert_eq!((t.format, t.width, t.height, t.bits_per_pixel, t.planes, t.data), (1, 160, 90, 24, 1, [0xFF, 0xD8, 0xFF, 0xD9].as_slice()));
|
||||
for cut in 0..Thumbnail::HEADER_LEN {
|
||||
assert!(Thumbnail::from_bytes(&b[..cut]).is_err());
|
||||
}
|
||||
assert!(Thumbnail::jfif_bytes(u32::MAX, 1, &[]).is_err());
|
||||
assert!(Thumbnail::jfif_bytes(40_000, 40_000, &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_info_roundtrip() {
|
||||
let r = version_info_resource(false);
|
||||
|
||||
+330
-1
@@ -22,7 +22,7 @@ use std::ops::Range;
|
||||
use crate::descriptor::{Descriptor, UnicodeString, Value};
|
||||
use crate::error::{PsdError, Result};
|
||||
use crate::file::PsdFile;
|
||||
use crate::io::{Reader, read_pascal};
|
||||
use crate::io::{Reader, WriteExt, read_pascal};
|
||||
use crate::layer::LayerRecord;
|
||||
|
||||
/// The most pixels a baked smart object may have: 2^24 (256 MiB as float RGBA). The size comes
|
||||
@@ -457,6 +457,203 @@ fn linked_file(item: &[u8], at: usize) -> Result<LinkedFile> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The four-character file type Photoshop records for an embedded file, from its first bytes:
|
||||
/// `8BPS` (`8BPB` for a PSB), `PNGf`, `JPEG`, `TIFF`, `GIFf`, or four spaces.
|
||||
pub fn file_type(bytes: &[u8]) -> [u8; 4] {
|
||||
match bytes {
|
||||
[b'8', b'B', b'P', b'S', 0, 2, ..] => *b"8BPB",
|
||||
[b'8', b'B', b'P', b'S', ..] => *b"8BPS",
|
||||
[0x89, b'P', b'N', b'G', ..] => *b"PNGf",
|
||||
[0xFF, 0xD8, ..] => *b"JPEG",
|
||||
[b'I', b'I', 42, 0, ..] | [b'M', b'M', 0, 42, ..] => *b"TIFF",
|
||||
[b'G', b'I', b'F', ..] => *b"GIFf",
|
||||
_ => *b" ",
|
||||
}
|
||||
}
|
||||
|
||||
/// One embedded-file record (`liFD`, version 7: no open descriptor, no child document, no
|
||||
/// modification time, unlocked) with its length field and padding to 4, ready to append to a
|
||||
/// linked layer block's data. The type is [`file_type`] of the bytes; the id is cut to 255 bytes.
|
||||
pub fn encode_embedded_file(uuid: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut item = b"liFD".to_vec();
|
||||
item.put_u32(7);
|
||||
let id = uuid.as_bytes().get(..uuid.len().min(255)).unwrap_or_default();
|
||||
item.put_u8(id.len() as u8);
|
||||
item.put(id);
|
||||
// NUL-terminated, the terminator counted (as Photoshop writes and reads it).
|
||||
UnicodeString::new_nul(name).write(&mut item);
|
||||
item.put(&file_type(bytes));
|
||||
item.put(b"8BIM");
|
||||
item.put_u64(bytes.len() as u64);
|
||||
item.put_u8(0);
|
||||
item.put(bytes);
|
||||
item.put_u32(0); // child document id (version 5)
|
||||
item.put_f64(0.0); // asset modification time (version 6)
|
||||
item.put_u8(0); // asset locked (version 7)
|
||||
let mut out = Vec::with_capacity(item.len() + 11);
|
||||
out.put_u64(item.len() as u64);
|
||||
out.put(&item);
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// One record of a linked layer block's data, unparsed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LinkedRecord<'a> {
|
||||
/// The record's unique id, when its header is readable (lossy UTF-8).
|
||||
pub uuid: Option<&'a [u8]>,
|
||||
/// The whole record: length field, record and padding.
|
||||
pub raw: &'a [u8],
|
||||
}
|
||||
|
||||
impl LinkedRecord<'_> {
|
||||
/// The unique id as text, when it is readable and valid UTF-8.
|
||||
pub fn uuid_str(&self) -> Option<&str> {
|
||||
std::str::from_utf8(self.uuid?).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// The record inside `data` at its start: (the record without its length field, the record
|
||||
/// with length field and padding). `None` when the length runs past the data.
|
||||
fn framed_record(data: &[u8]) -> Option<(&[u8], &[u8])> {
|
||||
let len = usize::try_from(u64::from_be_bytes(*data.first_chunk::<8>()?)).ok()?;
|
||||
let end = len.checked_add(8)?;
|
||||
let item = data.get(8..end)?;
|
||||
let padded = end.checked_next_multiple_of(4)?.min(data.len());
|
||||
Some((item, data.get(..padded)?))
|
||||
}
|
||||
|
||||
/// The unique id bytes of a record (after its type and version), when they are present.
|
||||
fn record_uuid(item: &[u8]) -> Option<&[u8]> {
|
||||
let n = usize::from(*item.get(8)?);
|
||||
item.get(9..9usize.checked_add(n)?)
|
||||
}
|
||||
|
||||
/// The records of a linked layer block's data, in order, each with its unique id when its header
|
||||
/// is readable. A tail that is no complete record is returned as one record without an id.
|
||||
pub fn linked_records(data: &[u8]) -> Vec<LinkedRecord<'_>> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = data;
|
||||
while !rest.is_empty() {
|
||||
let Some((item, raw)) = framed_record(rest) else {
|
||||
out.push(LinkedRecord { uuid: None, raw: rest });
|
||||
break;
|
||||
};
|
||||
out.push(LinkedRecord { uuid: record_uuid(item), raw });
|
||||
rest = rest.get(raw.len()..).unwrap_or_default();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The unique ids of every record of a linked layer block's data that has a readable one.
|
||||
pub fn linked_uuids(data: &[u8]) -> Vec<String> {
|
||||
linked_records(data).iter().filter_map(|r| r.uuid.map(|u| String::from_utf8_lossy(u).into_owned())).collect()
|
||||
}
|
||||
|
||||
/// A linked layer block's data with the records `keep` accepts (by unique id, lossy UTF-8) and
|
||||
/// every record without a readable id, then the records of `add` (see
|
||||
/// [`encode_embedded_file`]) appended. `None` when nothing would change.
|
||||
pub fn rebuild_linked_block(data: &[u8], keep: &dyn Fn(&str) -> bool, add: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
let records = linked_records(data);
|
||||
let kept = |r: &LinkedRecord<'_>| r.uuid.is_none_or(|u| keep(&String::from_utf8_lossy(u)));
|
||||
if records.iter().all(kept) && add.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for r in records.iter().filter(|r| kept(r)) {
|
||||
out.extend_from_slice(r.raw);
|
||||
}
|
||||
for a in add {
|
||||
out.extend_from_slice(a);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Only the complete records `keep(uuid, raw record)` accepts, for copying smart objects to
|
||||
/// another document: unlike [`rebuild_linked_block`], records without an id and a damaged tail
|
||||
/// are left out, so a copy never carries unrelated embedded files. An id must be valid UTF-8 to
|
||||
/// be selected: one that is not, but whose lossy text `keep` accepts, is an error, as is a
|
||||
/// selected record that is cut short. `Ok(None)` when every record is selected and complete
|
||||
/// (the data can be shared as it is).
|
||||
pub fn select_linked_records(data: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result<Option<Vec<u8>>> {
|
||||
let cut_short = || PsdError::invalid("a selected linked-layer record is cut short");
|
||||
let append = |out: &mut Vec<u8>, bytes: &[u8]| -> Result<()> {
|
||||
out.try_reserve(bytes.len()).map_err(|_| PsdError::OutOfMemory("not enough memory to copy linked-layer records"))?;
|
||||
out.extend_from_slice(bytes);
|
||||
Ok(())
|
||||
};
|
||||
let mut selected: Option<Vec<u8>> = None;
|
||||
let mut at = 0usize;
|
||||
while let Some(rest) = data.get(at..).filter(|r| !r.is_empty()) {
|
||||
let Some((item, raw)) = framed_record(rest) else {
|
||||
if let Some(item) = rest.get(8..)
|
||||
&& selection_uuid(item, rest, keep)?.is_some_and(|uuid| keep(uuid, rest))
|
||||
{
|
||||
return Err(cut_short());
|
||||
}
|
||||
if selected.is_none() {
|
||||
let mut prefix = Vec::new();
|
||||
append(&mut prefix, data.get(..at).ok_or_else(cut_short)?)?;
|
||||
selected = Some(prefix);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if selection_uuid(item, raw, keep)?.is_some_and(|uuid| keep(uuid, raw)) {
|
||||
if let Some(out) = &mut selected {
|
||||
append(out, raw)?;
|
||||
}
|
||||
} else if selected.is_none() {
|
||||
let mut prefix = Vec::new();
|
||||
append(&mut prefix, data.get(..at).ok_or_else(cut_short)?)?;
|
||||
selected = Some(prefix);
|
||||
}
|
||||
at = at.checked_add(raw.len()).ok_or_else(cut_short)?;
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
/// A record's id for selection: exact UTF-8, or an error when only its lossy text is selected.
|
||||
fn selection_uuid<'a>(item: &'a [u8], raw: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result<Option<&'a str>> {
|
||||
let Some(bytes) = record_uuid(item).filter(|b| !b.is_empty()) else { return Ok(None) };
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(uuid) => Ok(Some(uuid)),
|
||||
Err(_) if keep(&String::from_utf8_lossy(bytes), raw) => Err(PsdError::invalid("a linked-layer record id is not valid UTF-8")),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the complete record `record` (length field and padding included) is in a linked layer
|
||||
/// block's data, without decoding any file bytes.
|
||||
pub fn contains_linked_record(data: &[u8], record: &[u8]) -> bool {
|
||||
let mut rest = data;
|
||||
while let Some((_, raw)) = framed_record(rest) {
|
||||
if raw == record {
|
||||
return true;
|
||||
}
|
||||
let Some(next) = rest.get(raw.len()..) else { return false };
|
||||
rest = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The record with id `uuid` copied under `new_uuid` (at most 255 bytes), with its length
|
||||
/// field and padding; the rest of the record (embedded, external or alias data) is kept.
|
||||
pub fn copy_linked_record(data: &[u8], uuid: &str, new_uuid: &str) -> Option<Vec<u8>> {
|
||||
let record = linked_records(data).into_iter().find(|r| r.uuid == Some(uuid.as_bytes()))?;
|
||||
let (item, _) = framed_record(record.raw)?;
|
||||
let old_end = 9usize.checked_add(usize::from(*item.get(8)?))?;
|
||||
let new_len = u8::try_from(new_uuid.len()).ok()?;
|
||||
let mut copy = item.get(..8)?.to_vec();
|
||||
copy.push(new_len);
|
||||
copy.extend_from_slice(new_uuid.as_bytes());
|
||||
copy.extend_from_slice(item.get(old_end..)?);
|
||||
let mut out = Vec::with_capacity(copy.len() + 11);
|
||||
out.put_u64(copy.len() as u64);
|
||||
out.extend(copy);
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
impl PsdFile {
|
||||
/// Every linked file of the document's linked layer blocks, each with the index of the
|
||||
/// global block that holds it ([`LinkedFile::data`] is a range of that block's data). A
|
||||
@@ -533,6 +730,138 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// The embedded files of a block, as (id, name, bytes).
|
||||
fn embedded(data: &[u8]) -> Vec<(String, String, Vec<u8>)> {
|
||||
linked_files(data).into_iter().filter_map(|f| Some((f.uuid, f.name, data.get(f.data?)?.to_vec()))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_file_records_read_back_and_check() {
|
||||
let mut data = encode_embedded_file("abc-1", "a.png", &[0x89, b'P', b'N', b'G', 1]);
|
||||
data.extend(encode_embedded_file("def-2", "b.psb", b"8BPS\0\x02rest"));
|
||||
assert_eq!(
|
||||
embedded(&data),
|
||||
[("abc-1".into(), "a.png".into(), vec![0x89, b'P', b'N', b'G', 1]), ("def-2".into(), "b.psb".into(), b"8BPS\0\x02rest".to_vec())]
|
||||
);
|
||||
assert_eq!(linked_files(&data).iter().map(|f| f.file_type.clone()).collect::<Vec<_>>(), ["PNGf", "8BPB"]);
|
||||
TaggedBlock::new(*b"lnk2", data).check_structure().unwrap();
|
||||
for (bytes, ty) in [
|
||||
(&b"8BPS\0\x01"[..], b"8BPS"),
|
||||
(&[0xFF, 0xD8, 0xFF][..], b"JPEG"),
|
||||
(b"II*\0", b"TIFF"),
|
||||
(b"MM\0*", b"TIFF"),
|
||||
(b"GIF89a", b"GIFf"),
|
||||
(b"%PDF", b" "),
|
||||
] {
|
||||
assert_eq!(&file_type(bytes), ty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_are_pruned_and_extended() {
|
||||
let (a, b, c) =
|
||||
(encode_embedded_file("a", "a.png", &[1]), encode_embedded_file("b", "b.psb", &[2, 2]), encode_embedded_file("c", "c", &[7; 3]));
|
||||
let mut data = a.clone();
|
||||
data.extend(&b);
|
||||
assert_eq!(linked_uuids(&data), ["a", "b"]);
|
||||
assert_eq!(rebuild_linked_block(&data, &|_| true, &[]), None);
|
||||
let out = rebuild_linked_block(&data, &|u| u != "a", std::slice::from_ref(&c)).unwrap();
|
||||
assert_eq!(out, [b.clone(), c.clone()].concat());
|
||||
// A malformed tail is kept as it is.
|
||||
let mut bad = a.clone();
|
||||
bad.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]);
|
||||
assert!(rebuild_linked_block(&bad, &|_| true, std::slice::from_ref(&c)).unwrap().starts_with(&bad));
|
||||
assert!(linked_records(&[0, 0, 0, 0, 0, 0, 0, 200, 1, 2]).iter().all(|r| r.uuid.is_none()));
|
||||
assert!(linked_records(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_record_keeps_an_unknown_external_payload() {
|
||||
let mut raw = encode_embedded_file("old", "missing.psb", &[7; 5]);
|
||||
raw[8..12].copy_from_slice(b"liFE");
|
||||
let copy = copy_linked_record(&raw, "old", "independent-copy").unwrap();
|
||||
assert_eq!(linked_uuids(©), ["independent-copy"]);
|
||||
let original_len = u64::from_be_bytes(raw[..8].try_into().unwrap()) as usize;
|
||||
let copied_len = u64::from_be_bytes(copy[..8].try_into().unwrap()) as usize;
|
||||
assert_eq!(©[8..16], &raw[8..16]);
|
||||
assert_eq!(©[17 + "independent-copy".len()..8 + copied_len], &raw[20..8 + original_len]);
|
||||
assert!(copy_linked_record(&raw, "missing", "new").is_none());
|
||||
assert!(copy_linked_record(&raw, "old", &"x".repeat(256)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_records_leave_out_other_files_and_unidentified_pieces() {
|
||||
let (a, b) = (encode_embedded_file("a", "a.psb", &[1; 3]), encode_embedded_file("b", "b.psb", &[2; 4]));
|
||||
assert!(select_linked_records(&a, &|uuid, _| uuid == "a").unwrap().is_none(), "a block of only selected records can be shared");
|
||||
let mut data = a.clone();
|
||||
data.extend(&b);
|
||||
data.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]);
|
||||
assert_eq!(select_linked_records(&data, &|uuid, _| uuid == "a").unwrap(), Some(a.clone()));
|
||||
assert_eq!(select_linked_records(&data, &|uuid, _| uuid == "b").unwrap(), Some(b));
|
||||
assert_eq!(select_linked_records(&data, &|_, _| false).unwrap(), Some(Vec::new()));
|
||||
// A complete but unidentified record is skipped without losing a later selected one.
|
||||
let mut unreadable = 1u64.to_be_bytes().to_vec();
|
||||
unreadable.extend([7, 0, 0, 0]);
|
||||
unreadable.extend(&a);
|
||||
assert_eq!(select_linked_records(&unreadable, &|uuid, _| uuid == "a").unwrap(), Some(a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_external_and_alias_records_keep_unknown_payloads_verbatim() {
|
||||
for kind in [*b"liFE", *b"liFA", *b"next"] {
|
||||
let mut raw = encode_embedded_file("selected", "missing.psb", &[7; 5]);
|
||||
raw[8..12].copy_from_slice(&kind);
|
||||
raw[12..16].copy_from_slice(&42u32.to_be_bytes()); // an unknown future version
|
||||
let mut data = encode_embedded_file("unselected", "other.psb", &[9; 3]);
|
||||
data.extend(&raw);
|
||||
assert_eq!(select_linked_records(&data, &|uuid, _| uuid == "selected").unwrap(), Some(raw));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_selected_record_is_an_error_not_a_missing_file() {
|
||||
let mut raw = encode_embedded_file("selected", "missing.psb", &[7; 5]);
|
||||
raw[..8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert!(select_linked_records(&raw, &|uuid, _| uuid == "selected").unwrap_err().to_string().contains("cut short"));
|
||||
assert_eq!(select_linked_records(&raw, &|uuid, _| uuid == "other").unwrap(), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_are_matched_whole() {
|
||||
let (a, b, different) =
|
||||
(encode_embedded_file("a", "a.psb", &[1; 3]), encode_embedded_file("b", "b.psb", &[2; 4]), encode_embedded_file("a", "a.psb", &[9; 3]));
|
||||
let combined = [a.clone(), b.clone()].concat();
|
||||
assert!(contains_linked_record(&combined, &a) && contains_linked_record(&combined, &b));
|
||||
assert!(!contains_linked_record(&combined, &different));
|
||||
assert_eq!(select_linked_records(&a, &|_, raw| !contains_linked_record(&combined, raw)).unwrap(), Some(Vec::new()));
|
||||
assert!(select_linked_records(&different, &|_, raw| !contains_linked_record(&combined, raw)).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_ids_are_unidentified_even_when_asked_for() {
|
||||
let raw = encode_embedded_file("", "unidentified.psb", &[7; 5]);
|
||||
assert_eq!(select_linked_records(&raw, &|_, _| true).unwrap(), Some(Vec::new()));
|
||||
let mut cut = raw;
|
||||
cut[..8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert_eq!(select_linked_records(&cut, &|_, _| true).unwrap(), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_never_takes_a_lossy_id_for_a_record_identity() {
|
||||
let (mut a, mut b) = (encode_embedded_file("x", "a.psb", &[1; 3]), encode_embedded_file("x", "b.psb", &[2; 4]));
|
||||
a[17] = 0xff;
|
||||
b[17] = 0xfe;
|
||||
let mut data = [a, b].concat();
|
||||
assert_eq!(linked_uuids(&data), ["\u{fffd}", "\u{fffd}"]);
|
||||
assert!(select_linked_records(&data, &|uuid, _| uuid == "\u{fffd}").unwrap_err().to_string().contains("not valid UTF-8"));
|
||||
let valid = encode_embedded_file("valid", "valid.psb", &[3; 5]);
|
||||
data.extend(&valid);
|
||||
assert_eq!(select_linked_records(&data, &|uuid, _| uuid == "valid").unwrap(), Some(valid));
|
||||
// A literal replacement character can itself be a valid UTF-8 id.
|
||||
let unicode = encode_embedded_file("\u{fffd}", "unicode.psb", &[4; 2]);
|
||||
assert!(select_linked_records(&unicode, &|uuid, _| uuid == "\u{fffd}").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_layer_data_gives_the_id_quad_size_and_warp() {
|
||||
let quad = [20.0, 4.0, 40.0, 4.0, 40.0, 44.0, 20.0, 44.0];
|
||||
|
||||
@@ -513,6 +513,19 @@ pub(crate) fn is_sig(b: &[u8]) -> bool {
|
||||
b.starts_with(b"8BIM") || b.starts_with(b"8B64") || TaggedBlock::is_foreign_order_signature(b)
|
||||
}
|
||||
|
||||
/// The channels a `brst` block (channel blending restrictions) leaves out of blending, as a bit
|
||||
/// mask: bit `i` for channel `i`. The block is a list of big-endian `u32` channel indices;
|
||||
/// indices above 31 and a trailing partial index are ignored.
|
||||
pub fn channel_restrictions(data: &[u8]) -> u32 {
|
||||
data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).filter(|&i| i < 32).fold(0, |m, i| m | 1 << i)
|
||||
}
|
||||
|
||||
/// The data of a `brst` block for the channels `mask` leaves out ([`channel_restrictions`]);
|
||||
/// `None` when every channel blends, which Photoshop writes as no block.
|
||||
pub fn channel_restrictions_data(mask: u32) -> Option<Vec<u8>> {
|
||||
(mask != 0).then(|| (0..32u32).filter(|i| mask & 1 << i != 0).flat_map(u32::to_be_bytes).collect())
|
||||
}
|
||||
|
||||
/// Reads tagged blocks until the reader is exhausted. Bytes that do not form
|
||||
/// a block (fewer than 12 bytes or no valid signature) are returned as
|
||||
/// trailing data.
|
||||
@@ -600,6 +613,16 @@ pub(crate) fn write_blocks_to<W: std::io::Write + std::io::Seek + ?Sized>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn channel_restrictions_round_trip_as_a_mask() {
|
||||
let data = [0u32, 2, 40].iter().flat_map(|i| i.to_be_bytes()).chain([0, 0]).collect::<Vec<u8>>();
|
||||
assert_eq!(channel_restrictions(&data), 0b101);
|
||||
assert_eq!(channel_restrictions_data(0b101), Some([0u32, 2].iter().flat_map(|i| i.to_be_bytes()).collect()));
|
||||
assert_eq!(channel_restrictions_data(0), None);
|
||||
assert_eq!(channel_restrictions(&[]), 0);
|
||||
assert_eq!(channel_restrictions(&channel_restrictions_data(u32::MAX).unwrap()), u32::MAX);
|
||||
}
|
||||
|
||||
fn rt(blocks: &[TaggedBlock], version: Version) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
write_blocks(&mut out, blocks, version, 2).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user