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:
Dylan M. Taylor
2026-11-11 21:35:45 -05:00
committed by GitHub
co-authored by Omar Saidahmed
parent e98f3d47b3
commit 40ff186888
3 changed files with 429 additions and 1 deletions
+76
View File
@@ -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
View File
@@ -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(&copy), ["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!(&copy[8..16], &raw[8..16]);
assert_eq!(&copy[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];
+23
View File
@@ -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();