Files
photocraft/crates/psd/src/slices.rs
a7301f1db8 PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198) (#5207)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gert Pellin <gert@pellin.be>
Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Finn Newick <finnnk@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Diego Chagas <drochachagas@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-15 00:51:37 -04:00

497 lines
19 KiB
Rust

//! Slices image resource (id 1050), from the Adobe Photoshop File Format Specification.
//!
//! Version 6 is a fixed binary layout (bounding rect, group name, then per slice: ids, origin,
//! optional layer id, name, type, rect, URL / target / message / alt strings, cell text and
//! alignment, background colour), optionally followed by a versioned descriptor carrying extra
//! per-slice data (layer-slice outsets). Versions 7 and 8 store everything as one versioned
//! descriptor (`baseName`, `bounds`, `slices` list).
//!
//! [`SlicesResource::to_bytes`] writes version 6, the documented binary layout every Photoshop
//! version reads. Unknown fields are not kept here; callers that need a byte-exact round trip
//! keep the raw resource while the slices are unchanged.
use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
use crate::error::{PsdError, Result};
use crate::io::{Reader, WriteExt};
/// Resource id of the slices resource.
pub const SLICES: u16 = 1050;
/// One slice record.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SliceRecord {
/// Slice id.
pub id: u32,
/// Group id.
pub group_id: u32,
/// Origin: 0 auto-generated, 1 layer-based, 2 user-drawn.
pub origin: u32,
/// Layer id of a layer-based slice (the layer record's `lyid`).
pub layer_id: Option<u32>,
/// Slice name.
pub name: String,
/// Type: 0 no image, 1 image, 2 table.
pub kind: u32,
/// Left, top, right, bottom in document pixels.
pub rect: [i32; 4],
/// URL.
pub url: String,
/// Target frame.
pub target: String,
/// Status-bar message.
pub message: String,
/// Alt tag.
pub alt: String,
/// Cell text is HTML.
pub cell_text_is_html: bool,
/// Cell text.
pub cell_text: String,
/// Horizontal alignment.
pub horizontal_align: u32,
/// Vertical alignment.
pub vertical_align: u32,
/// Background colour as alpha, red, green, blue.
pub color: [u8; 4],
/// Layer-based slice outsets: top, left, bottom, right.
pub outsets: [i32; 4],
}
/// The whole slices resource.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SlicesResource {
/// Version read (6, 7 or 8); writing always produces 6.
pub version: u32,
/// Bounding rectangle of all slices: top, left, bottom, right.
pub bounds: [i32; 4],
/// Name of the slice group (the base name).
pub group_name: String,
/// Slices, including auto-generated ones as Photoshop saves them.
pub slices: Vec<SliceRecord>,
}
fn ustr(r: &mut Reader<'_>) -> Result<String> {
Ok(UnicodeString::read(r)?.to_string_lossy())
}
fn put_ustr(out: &mut Vec<u8>, s: &str) {
UnicodeString::new(s).write(out);
}
// ---------- descriptor helpers (versions 7/8 and the v6 tail) ----------
/// A number as `i64`; float casts saturate (NaN reads as 0).
fn d_int(d: &Descriptor, key: &str) -> Option<i64> {
match d.get(key)? {
Value::Integer(v) => Some(i64::from(*v)),
Value::LargeInteger(v) => Some(*v),
Value::Double(v) => Some(*v as i64),
Value::UnitFloat { value, .. } => Some(*value as i64),
_ => None,
}
}
/// A coordinate, clamped to the `i32` range instead of wrapping.
fn d_i32(d: &Descriptor, key: &str) -> Option<i32> {
d_int(d, key).map(|v| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32)
}
/// An id. A 32-bit `long` holds the id's bits (ids past `i32::MAX` are written negative); any
/// other number outside the `u32` range is no id at all rather than a wrapped one.
fn d_u32(d: &Descriptor, key: &str) -> Option<u32> {
match d.get(key)? {
Value::Integer(v) => Some(*v as u32),
_ => u32::try_from(d_int(d, key)?).ok(),
}
}
fn d_text(d: &Descriptor, key: &str) -> String {
match d.get(key) {
Some(Value::Text(t)) => t.to_string_lossy(),
_ => String::new(),
}
}
fn d_enum(d: &Descriptor, key: &str) -> Option<String> {
match d.get(key)? {
Value::Enumerated { value, .. } => Some(String::from_utf8_lossy(value.as_bytes()).into_owned()),
_ => None,
}
}
fn d_bool(d: &Descriptor, key: &str) -> bool {
matches!(d.get(key), Some(Value::Boolean(true)))
}
fn d_bounds(d: &Descriptor, key: &str) -> Option<[i32; 4]> {
let Value::Descriptor(b) = d.get(key)? else { return None };
let g = |k: &str| d_i32(b, k).unwrap_or(0);
// top, left, bottom, right
Some([g("Top "), g("Left"), g("Btom"), g("Rght")])
}
fn origin_from(s: &str) -> u32 {
match s {
"autoGenerated" => 0,
"layerGenerated" | "layer" => 1,
_ => 2,
}
}
fn kind_from(s: &str) -> u32 {
match s {
"noImage" => 0,
"Tbl " | "table" => 2,
_ => 1,
}
}
fn slice_from_descriptor(d: &Descriptor) -> SliceRecord {
let b = d_bounds(d, "bounds").unwrap_or_default();
let color = match (d_enum(d, "bgColorType").as_deref(), d.get("bgColor")) {
(Some("Clr "), Some(Value::Descriptor(c))) => {
let g = |k: &str| d_int(c, k).unwrap_or(0).clamp(0, 255) as u8;
let a = if c.get("alpha").is_some() { g("alpha") } else { 255 };
[a, g("Rd "), g("Grn "), g("Bl ")]
}
_ => [0; 4],
};
SliceRecord {
id: d_u32(d, "sliceID").unwrap_or(0),
group_id: d_u32(d, "groupID").unwrap_or(0),
origin: d_enum(d, "origin").map_or(2, |s| origin_from(&s)),
layer_id: d_u32(d, "layerID"),
name: d_text(d, "Nm "),
kind: d_enum(d, "Type").map_or(1, |s| kind_from(&s)),
// Descriptor bounds are top/left/bottom/right; records use left/top/right/bottom.
rect: [b[1], b[0], b[3], b[2]],
url: d_text(d, "url"),
target: d_text(d, "null"),
message: d_text(d, "Msge"),
alt: d_text(d, "altTag"),
cell_text_is_html: d_bool(d, "cellTextIsHTML"),
cell_text: d_text(d, "cellText"),
horizontal_align: 0,
vertical_align: 0,
color,
outsets: [
d_i32(d, "topOutset").unwrap_or(0),
d_i32(d, "leftOutset").unwrap_or(0),
d_i32(d, "bottomOutset").unwrap_or(0),
d_i32(d, "rightOutset").unwrap_or(0),
],
}
}
fn bounds_descriptor(tlbr: [i32; 4]) -> Value {
Value::Descriptor(
Descriptor::new("Rct1")
.with("Top ", Value::Integer(tlbr[0]))
.with("Left", Value::Integer(tlbr[1]))
.with("Btom", Value::Integer(tlbr[2]))
.with("Rght", Value::Integer(tlbr[3])),
)
}
fn enumerated(ty: &str, v: &str) -> Value {
Value::Enumerated { type_id: Id::new(ty), value: Id::new(v) }
}
impl SlicesResource {
/// Parses resource data (versions 6, 7 and 8).
pub fn from_bytes(data: &[u8]) -> Result<Self> {
let mut r = Reader::new(data);
let version = r.u32()?;
match version {
6 => Self::read_v6(&mut r),
7 | 8 => {
let vd = VersionedDescriptor::read(&mut r)?;
let d = &vd.descriptor;
let slices = match d.get("slices") {
Some(Value::List(items)) => items
.iter()
.filter_map(|v| match v {
Value::Descriptor(s) => Some(slice_from_descriptor(s)),
_ => None,
})
.collect(),
_ => Vec::new(),
};
Ok(SlicesResource { version, bounds: d_bounds(d, "bounds").unwrap_or_default(), group_name: d_text(d, "baseName"), slices })
}
v => Err(PsdError::invalid(format!("unsupported slices resource version {v}"))),
}
}
fn read_v6(r: &mut Reader<'_>) -> Result<Self> {
let bounds = [r.i32()?, r.i32()?, r.i32()?, r.i32()?];
let group_name = ustr(r)?;
let n = r.u32()? as usize;
if n > r.remaining() / 16 {
return Err(PsdError::invalid("slice count exceeds the resource size"));
}
let mut slices = Vec::with_capacity(n);
for _ in 0..n {
let id = r.u32()?;
let group_id = r.u32()?;
let origin = r.u32()?;
let layer_id = if origin == 1 { Some(r.u32()?) } else { None };
let name = ustr(r)?;
let kind = r.u32()?;
let rect = [r.i32()?, r.i32()?, r.i32()?, r.i32()?];
let url = ustr(r)?;
let target = ustr(r)?;
let message = ustr(r)?;
let alt = ustr(r)?;
let cell_text_is_html = r.u8()? != 0;
let cell_text = ustr(r)?;
let horizontal_align = r.u32()?;
let vertical_align = r.u32()?;
let color = [r.u8()?, r.u8()?, r.u8()?, r.u8()?];
slices.push(SliceRecord {
id,
group_id,
origin,
layer_id,
name,
kind,
rect,
url,
target,
message,
alt,
cell_text_is_html,
cell_text,
horizontal_align,
vertical_align,
color,
outsets: [0; 4],
});
}
// Optional trailing descriptor (Photoshop 7+): per-slice extras such as outsets.
if r.remaining() >= 4
&& let Ok(vd) = VersionedDescriptor::read(r)
&& let Some(Value::List(items)) = vd.descriptor.get("slices")
{
for v in items {
let Value::Descriptor(d) = v else { continue };
// An entry without a usable id belongs to no slice (not to slice 0).
let Some(id) = d_u32(d, "sliceID") else { continue };
let extra = slice_from_descriptor(d);
if let Some(s) = slices.iter_mut().find(|s| s.id == id) {
s.outsets = extra.outsets;
}
}
}
Ok(SlicesResource { version: 6, bounds, group_name, slices })
}
/// Serializes as version 6. A trailing descriptor with the outsets is added when any slice
/// has them, the way Photoshop extends the version 6 layout.
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
out.put_u32(6);
for v in self.bounds {
out.put_i32(v);
}
put_ustr(&mut out, &self.group_name);
out.put_u32(self.slices.len() as u32);
for s in &self.slices {
out.put_u32(s.id);
out.put_u32(s.group_id);
out.put_u32(s.origin);
if s.origin == 1 {
out.put_u32(s.layer_id.unwrap_or(0));
}
put_ustr(&mut out, &s.name);
out.put_u32(s.kind);
for v in s.rect {
out.put_i32(v);
}
put_ustr(&mut out, &s.url);
put_ustr(&mut out, &s.target);
put_ustr(&mut out, &s.message);
put_ustr(&mut out, &s.alt);
out.put_u8(u8::from(s.cell_text_is_html));
put_ustr(&mut out, &s.cell_text);
out.put_u32(s.horizontal_align);
out.put_u32(s.vertical_align);
out.put(&s.color);
}
if self.slices.iter().any(|s| s.outsets != [0; 4]) {
let list = self
.slices
.iter()
.map(|s| {
Value::Descriptor(
Descriptor::new("slice")
.with("sliceID", Value::Integer(s.id as i32))
.with("topOutset", Value::Integer(s.outsets[0]))
.with("leftOutset", Value::Integer(s.outsets[1]))
.with("bottomOutset", Value::Integer(s.outsets[2]))
.with("rightOutset", Value::Integer(s.outsets[3])),
)
})
.collect();
VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(list))).write(&mut out);
}
out
}
/// Serializes in the version 7 descriptor form (what newer Photoshop versions write).
pub fn to_descriptor_bytes(&self) -> Vec<u8> {
let list = self
.slices
.iter()
.map(|s| {
let origin = match s.origin {
0 => "autoGenerated",
1 => "layerGenerated",
_ => "userGenerated",
};
let kind = match s.kind {
0 => "noImage",
2 => "Tbl ",
_ => "Img ",
};
let mut d = Descriptor::new("slice")
.with("sliceID", Value::Integer(s.id as i32))
.with("groupID", Value::Integer(s.group_id as i32))
.with("origin", enumerated("ESliceOrigin", origin));
if let Some(l) = s.layer_id.filter(|_| s.origin == 1) {
d = d.with("layerID", Value::Integer(l as i32));
}
d.with("Nm ", Value::Text(UnicodeString::new(&s.name)))
.with("Type", enumerated("ESliceType", kind))
.with("bounds", bounds_descriptor([s.rect[1], s.rect[0], s.rect[3], s.rect[2]]))
.with("url", Value::Text(UnicodeString::new(&s.url)))
.with("null", Value::Text(UnicodeString::new(&s.target)))
.with("Msge", Value::Text(UnicodeString::new(&s.message)))
.with("altTag", Value::Text(UnicodeString::new(&s.alt)))
.with("cellTextIsHTML", Value::Boolean(s.cell_text_is_html))
.with("cellText", Value::Text(UnicodeString::new(&s.cell_text)))
.with("horzAlign", enumerated("ESliceHorzAlign", "default"))
.with("vertAlign", enumerated("ESliceVertAlign", "default"))
.with("bgColorType", enumerated("ESliceBGColorType", "None"))
.with("topOutset", Value::Integer(s.outsets[0]))
.with("leftOutset", Value::Integer(s.outsets[1]))
.with("bottomOutset", Value::Integer(s.outsets[2]))
.with("rightOutset", Value::Integer(s.outsets[3]))
})
.map(Value::Descriptor)
.collect();
let d = Descriptor::new("null")
.with("bounds", bounds_descriptor(self.bounds))
.with("slices", Value::List(list))
.with("baseName", Value::Text(UnicodeString::new(&self.group_name)));
let mut out = Vec::new();
out.put_u32(7);
VersionedDescriptor::new(d).write(&mut out);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> SlicesResource {
SlicesResource {
version: 6,
bounds: [0, 0, 80, 100],
group_name: "site".into(),
slices: vec![
SliceRecord { id: 0, origin: 0, kind: 1, rect: [0, 0, 100, 10], ..Default::default() },
SliceRecord {
id: 1,
group_id: 0,
origin: 2,
name: "logo".into(),
kind: 1,
rect: [20, 10, 60, 30],
url: "https://example.org/".into(),
target: "_blank".into(),
message: "hi".into(),
alt: "Logo".into(),
cell_text_is_html: true,
cell_text: "<b>x</b>".into(),
horizontal_align: 1,
vertical_align: 2,
color: [255, 10, 20, 30],
..Default::default()
},
SliceRecord { id: 2, origin: 1, layer_id: Some(7), kind: 0, rect: [5, 40, 25, 70], outsets: [1, 2, 3, 4], ..Default::default() },
],
}
}
#[test]
fn v6_roundtrips() {
let s = sample();
let bytes = s.to_bytes();
let back = SlicesResource::from_bytes(&bytes).unwrap();
assert_eq!(back, s);
assert_eq!(back.to_bytes(), bytes, "byte-stable");
}
#[test]
fn v7_descriptor_parses() {
let s = sample();
let back = SlicesResource::from_bytes(&s.to_descriptor_bytes()).unwrap();
assert_eq!(back.version, 7);
assert_eq!(back.group_name, "site");
assert_eq!(back.bounds, s.bounds);
assert_eq!(back.slices.len(), 3);
assert_eq!(back.slices[1].rect, [20, 10, 60, 30]);
assert_eq!(back.slices[1].alt, "Logo");
assert_eq!(back.slices[1].target, "_blank");
assert_eq!(back.slices[2].origin, 1);
assert_eq!(back.slices[2].layer_id, Some(7));
assert_eq!(back.slices[2].kind, 0);
assert_eq!(back.slices[2].outsets, [1, 2, 3, 4]);
}
#[test]
fn out_of_range_descriptor_numbers_do_not_wrap() {
// sliceID 2^32+1 used to read as slice 1 and take that slice's outsets.
let wide = |key: &str| Descriptor::new("slice").with("sliceID", Value::LargeInteger((1 << 32) + 1)).with(key, Value::Integer(9));
let mut plain = sample();
for s in &mut plain.slices {
s.outsets = [0; 4];
}
let mut bytes = plain.to_bytes();
VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(wide("topOutset"))]))).write(&mut bytes);
let back = SlicesResource::from_bytes(&bytes).unwrap();
assert!(back.slices.iter().all(|s| s.outsets == [0; 4]), "{:?}", back.slices);
// Descriptor form: ids out of the u32 range fall back to 0, coordinates clamp.
let d = wide("leftOutset")
.with("layerID", Value::LargeInteger(-1))
.with("bottomOutset", Value::LargeInteger(1 << 40))
.with("rightOutset", Value::Double(-1e12))
.with("bounds", Value::Descriptor(Descriptor::new("Rct1").with("Top ", Value::LargeInteger((1 << 32) + 5))));
let s = slice_from_descriptor(&d);
assert_eq!((s.id, s.layer_id), (0, None));
assert_eq!(s.outsets, [0, 9, i32::MAX, i32::MIN]);
assert_eq!(s.rect[1], i32::MAX);
// A 32-bit long keeps the id's bits, as `to_descriptor_bytes` writes ids past i32::MAX.
assert_eq!(slice_from_descriptor(&Descriptor::new("slice").with("sliceID", Value::Integer(-1))).id, u32::MAX);
}
#[test]
fn malformed_input_is_an_error() {
let bytes = sample().to_bytes();
// Cuts inside the records; a damaged trailing descriptor is ignored on purpose.
for cut in [0, 3, 10, 30, 120] {
assert!(SlicesResource::from_bytes(&bytes[..cut]).is_err(), "cut at {cut}");
}
let mut huge = 6u32.to_be_bytes().to_vec();
huge.extend([0u8; 16]);
huge.extend(0u32.to_be_bytes());
huge.extend(u32::MAX.to_be_bytes());
assert!(SlicesResource::from_bytes(&huge).is_err());
assert!(SlicesResource::from_bytes(&9u32.to_be_bytes()).is_err());
}
}