mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 07:46:08 +00:00
craft-psd: typed views of type layers, smart objects and adjustments (#957)
* 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. --------- Co-authored-by: Omar Saidahmed <osaidahm@uwaterloo.ca>
This commit is contained in:
co-authored by
Omar Saidahmed
parent
55d120f201
commit
430a908f68
@@ -0,0 +1,295 @@
|
||||
//! Adjustment and solid colour fill layers: the settings a reader needs to show them, as
|
||||
//! read-only views of their blocks (`nvrt`, `brit`, `CgEd`, `hue2`, `hue `, `levl`, `expA`,
|
||||
//! `SoCo`) and of the layer effects (`lfx2`).
|
||||
//!
|
||||
//! Only the composite (master) settings are read: the per-channel levels and the per-range
|
||||
//! hue/saturation edits are not. The blocks stay raw in the model and write back unchanged.
|
||||
//! Came from EffectCraft's `effectcraft-psd`.
|
||||
|
||||
use crate::descriptor::Descriptor;
|
||||
use crate::error::Result;
|
||||
use crate::io::Reader;
|
||||
use crate::layer::LayerRecord;
|
||||
|
||||
/// The adjustment a layer applies.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Adjustment {
|
||||
/// Invert (`nvrt`).
|
||||
Invert,
|
||||
/// Brightness/Contrast (`brit`, or `CgEd` when the layer has one).
|
||||
BrightnessContrast {
|
||||
/// Brightness.
|
||||
brightness: f64,
|
||||
/// Contrast.
|
||||
contrast: f64,
|
||||
/// The legacy algorithm: always for a `brit` block alone, `useLegacy` for `CgEd`.
|
||||
legacy: bool,
|
||||
},
|
||||
/// Hue/Saturation (`hue2`, `hue `): the colorize values when colorizing, else the master edit.
|
||||
HueSaturation {
|
||||
/// Hue.
|
||||
hue: f64,
|
||||
/// Saturation.
|
||||
saturation: f64,
|
||||
/// Lightness.
|
||||
lightness: f64,
|
||||
/// Colorize.
|
||||
colorize: bool,
|
||||
},
|
||||
/// Levels (`levl`): the composite channel's record.
|
||||
Levels {
|
||||
/// Input black, 0..255.
|
||||
in_black: f64,
|
||||
/// Input white, 0..255.
|
||||
in_white: f64,
|
||||
/// Gamma (the file stores it times 100).
|
||||
gamma: f64,
|
||||
/// Output black, 0..255.
|
||||
out_black: f64,
|
||||
/// Output white, 0..255.
|
||||
out_white: f64,
|
||||
},
|
||||
/// Exposure (`expA`). A value that is not finite reads as its default (0, 0, 1).
|
||||
Exposure {
|
||||
/// Exposure.
|
||||
exposure: f64,
|
||||
/// Offset.
|
||||
offset: f64,
|
||||
/// Gamma correction.
|
||||
gamma: f64,
|
||||
},
|
||||
/// Another adjustment, by its block key (`curv`, `blnc`, `post`, `thrs`, `selc`, `mixr`,
|
||||
/// `phfl`, `vibA`, `blwh`, `grdm`, `clrL`): its settings are in the layer's block.
|
||||
Other([u8; 4]),
|
||||
}
|
||||
|
||||
/// Keys of the adjustment blocks that are reported as [`Adjustment::Other`].
|
||||
const OTHER_KEYS: [&[u8; 4]; 11] = [b"curv", b"blnc", b"post", b"thrs", b"selc", b"mixr", b"phfl", b"vibA", b"blwh", b"grdm", b"clrL"];
|
||||
|
||||
impl Adjustment {
|
||||
/// Reads the data of the adjustment block `key`. `None` when `key` is not one of the typed
|
||||
/// adjustment blocks.
|
||||
pub fn from_block(key: &[u8; 4], data: &[u8]) -> Option<Result<Adjustment>> {
|
||||
let mut r = Reader::new(data);
|
||||
Some(match key {
|
||||
b"nvrt" => Ok(Adjustment::Invert),
|
||||
b"brit" => brightness(&mut r),
|
||||
b"CgEd" => content_generator(&mut r),
|
||||
b"hue2" | b"hue " => hue_saturation(&mut r),
|
||||
b"levl" => levels(&mut r),
|
||||
b"expA" => exposure(&mut r),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn brightness(r: &mut Reader<'_>) -> Result<Adjustment> {
|
||||
Ok(Adjustment::BrightnessContrast { brightness: f64::from(r.i16()?), contrast: f64::from(r.i16()?), legacy: true })
|
||||
}
|
||||
|
||||
fn content_generator(r: &mut Reader<'_>) -> Result<Adjustment> {
|
||||
let _version = r.u32()?;
|
||||
let d = Descriptor::read(r)?;
|
||||
Ok(Adjustment::BrightnessContrast {
|
||||
brightness: d.get_number("Brgh").unwrap_or(0.0),
|
||||
contrast: d.get_number("Cntr").unwrap_or(0.0),
|
||||
legacy: d.get_bool("useLegacy").unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn hue_saturation(r: &mut Reader<'_>) -> Result<Adjustment> {
|
||||
let _version = r.u16()?;
|
||||
let colorize = r.u8()? != 0;
|
||||
r.skip(1)?;
|
||||
let mut three = || -> Result<[f64; 3]> { Ok([f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?)]) };
|
||||
let colorized = three()?;
|
||||
let master = three()?;
|
||||
let [hue, saturation, lightness] = if colorize { colorized } else { master };
|
||||
Ok(Adjustment::HueSaturation { hue, saturation, lightness, colorize })
|
||||
}
|
||||
|
||||
fn levels(r: &mut Reader<'_>) -> Result<Adjustment> {
|
||||
let _version = r.u16()?;
|
||||
let (in_black, in_white, out_black, out_white, gamma) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?, r.i16()?);
|
||||
Ok(Adjustment::Levels {
|
||||
in_black: f64::from(in_black),
|
||||
in_white: f64::from(in_white),
|
||||
gamma: f64::from(gamma) / 100.0,
|
||||
out_black: f64::from(out_black),
|
||||
out_white: f64::from(out_white),
|
||||
})
|
||||
}
|
||||
|
||||
fn exposure(r: &mut Reader<'_>) -> Result<Adjustment> {
|
||||
let _version = r.u16()?;
|
||||
let mut finite = |default: f64| -> Result<f64> {
|
||||
let v = f64::from(f32::from_be_bytes(r.array()?));
|
||||
Ok(if v.is_finite() { v } else { default })
|
||||
};
|
||||
Ok(Adjustment::Exposure { exposure: finite(0.0)?, offset: finite(0.0)?, gamma: finite(1.0)? })
|
||||
}
|
||||
|
||||
/// Reads the data of a `SoCo` block: the fill colour as RGB in 0..1 (see
|
||||
/// [`Descriptor::get_color`]), `None` for a colour model that is not RGB or gray.
|
||||
pub fn solid_color(data: &[u8]) -> Result<Option<[f64; 3]>> {
|
||||
let mut r = Reader::new(data);
|
||||
let _version = r.u32()?;
|
||||
Ok(Descriptor::read(&mut r)?.get_color("Clr "))
|
||||
}
|
||||
|
||||
/// Reads the data of an `lfx2` block: the layer effects descriptor.
|
||||
pub fn effects(data: &[u8]) -> Result<Descriptor> {
|
||||
let mut r = Reader::new(data);
|
||||
let _effects_version = r.u32()?;
|
||||
let _descriptor_version = r.u32()?;
|
||||
Descriptor::read(&mut r)
|
||||
}
|
||||
|
||||
impl LayerRecord {
|
||||
/// The adjustment the layer applies, when it is an adjustment layer. Blocks are taken in
|
||||
/// order: a typed block replaces what came before, except that a `brit` block or one of
|
||||
/// the untyped adjustments ([`Adjustment::Other`]) only counts when nothing was found yet
|
||||
/// (Photoshop writes `brit` beside `CgEd`). A malformed block is skipped.
|
||||
pub fn adjustment(&self) -> Option<Adjustment> {
|
||||
let mut found = None;
|
||||
for b in &self.blocks {
|
||||
if OTHER_KEYS.contains(&&b.key) {
|
||||
found = found.or(Some(Adjustment::Other(b.key)));
|
||||
} else if let Some(Ok(a)) = Adjustment::from_block(&b.key, &b.data)
|
||||
&& (&b.key != b"brit" || found.is_none())
|
||||
{
|
||||
found = Some(a);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The colour of a solid colour fill layer (`SoCo`), RGB in 0..1.
|
||||
pub fn solid_color(&self) -> Option<[f64; 3]> {
|
||||
solid_color(&self.block(b"SoCo")?.data).ok()?
|
||||
}
|
||||
|
||||
/// The layer effects descriptor (`lfx2`). A malformed block counts as absent.
|
||||
pub fn effects(&self) -> Option<Descriptor> {
|
||||
effects(&self.block(b"lfx2")?.data).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::descriptor::Value;
|
||||
use crate::io::WriteExt;
|
||||
use crate::tagged::TaggedBlock;
|
||||
|
||||
fn layer(blocks: Vec<([u8; 4], Vec<u8>)>) -> LayerRecord {
|
||||
LayerRecord { blocks: blocks.into_iter().map(|(k, d)| TaggedBlock::new(k, d)).collect(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn i16s(prefix: &[u8], v: &[i16]) -> Vec<u8> {
|
||||
let mut out = prefix.to_vec();
|
||||
for x in v {
|
||||
out.put_i16(*x);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn versioned(d: Descriptor) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.put_u32(16);
|
||||
d.write(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_typed_adjustment_reads_its_settings() {
|
||||
assert_eq!(layer(vec![(*b"nvrt", vec![])]).adjustment(), Some(Adjustment::Invert));
|
||||
let brit = i16s(&[], &[40, -15, 127, 0]);
|
||||
assert_eq!(
|
||||
layer(vec![(*b"brit", brit.clone())]).adjustment(),
|
||||
Some(Adjustment::BrightnessContrast { brightness: 40.0, contrast: -15.0, legacy: true })
|
||||
);
|
||||
// Version 2, colorize off: the master edit is the second triple.
|
||||
let hue = i16s(&[0, 2, 0, 0], &[10, 20, 30, -40, 50, -60]);
|
||||
assert_eq!(
|
||||
layer(vec![(*b"hue2", hue.clone())]).adjustment(),
|
||||
Some(Adjustment::HueSaturation { hue: -40.0, saturation: 50.0, lightness: -60.0, colorize: false })
|
||||
);
|
||||
let mut colorized = hue;
|
||||
colorized[2] = 1;
|
||||
assert_eq!(
|
||||
layer(vec![(*b"hue ", colorized)]).adjustment(),
|
||||
Some(Adjustment::HueSaturation { hue: 10.0, saturation: 20.0, lightness: 30.0, colorize: true })
|
||||
);
|
||||
let levl = i16s(&[0, 2], &[12, 240, 5, 250, 150]);
|
||||
assert_eq!(
|
||||
layer(vec![(*b"levl", levl)]).adjustment(),
|
||||
Some(Adjustment::Levels { in_black: 12.0, in_white: 240.0, gamma: 1.5, out_black: 5.0, out_white: 250.0 })
|
||||
);
|
||||
let mut exp = vec![0, 1];
|
||||
for v in [1.5f32, -0.25, 0.5] {
|
||||
exp.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
assert_eq!(layer(vec![(*b"expA", exp)]).adjustment(), Some(Adjustment::Exposure { exposure: 1.5, offset: -0.25, gamma: 0.5 }));
|
||||
assert_eq!(layer(vec![(*b"curv", vec![1, 2, 3])]).adjustment(), Some(Adjustment::Other(*b"curv")));
|
||||
assert_eq!(layer(vec![(*b"luni", vec![0; 4])]).adjustment(), None);
|
||||
assert!(Adjustment::from_block(b"luni", &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_descriptor_form_of_brightness_wins_over_the_legacy_block_in_either_order() {
|
||||
let cged = versioned(
|
||||
Descriptor::new("null").with("Brgh", Value::Integer(25)).with("Cntr", Value::Integer(-5)).with("useLegacy", Value::Boolean(false)),
|
||||
);
|
||||
let brit = i16s(&[], &[1, 2, 127, 0]);
|
||||
let want = Some(Adjustment::BrightnessContrast { brightness: 25.0, contrast: -5.0, legacy: false });
|
||||
assert_eq!(layer(vec![(*b"brit", brit.clone()), (*b"CgEd", cged.clone())]).adjustment(), want);
|
||||
assert_eq!(layer(vec![(*b"CgEd", cged), (*b"brit", brit)]).adjustment(), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_values_that_are_not_finite_read_as_their_defaults() {
|
||||
let mut exp = vec![0, 1];
|
||||
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
exp.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
assert_eq!(layer(vec![(*b"expA", exp)]).adjustment(), Some(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_block_is_an_error_and_the_layer_skips_it() {
|
||||
for (key, len) in [(b"brit", 4usize), (b"hue2", 16), (b"levl", 12), (b"expA", 14)] {
|
||||
for cut in 0..len {
|
||||
assert!(matches!(Adjustment::from_block(key, &vec![0; cut]), Some(Err(_))), "{} cut {cut}", String::from_utf8_lossy(key));
|
||||
}
|
||||
assert!(matches!(Adjustment::from_block(key, &vec![0; len]), Some(Ok(_))));
|
||||
}
|
||||
assert_eq!(layer(vec![(*b"levl", vec![0; 3])]).adjustment(), None);
|
||||
// The damaged block does not hide a good one.
|
||||
assert_eq!(layer(vec![(*b"nvrt", vec![]), (*b"levl", vec![0; 3])]).adjustment(), Some(Adjustment::Invert));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_colour_and_effects_read_their_descriptors() {
|
||||
let rgb = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(127.5)).with("Bl ", Value::Double(0.0));
|
||||
let soco = versioned(Descriptor::new("null").with("Clr ", Value::Descriptor(rgb)));
|
||||
let mut lfx = vec![0, 0, 0, 0];
|
||||
lfx.extend(versioned(Descriptor::new("null").with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: 100.0 })));
|
||||
let l = layer(vec![(*b"SoCo", soco.clone()), (*b"lfx2", lfx)]);
|
||||
assert_eq!(l.solid_color(), Some([1.0, 0.5, 0.0]));
|
||||
assert_eq!(l.effects().unwrap().get_number("Scl "), Some(100.0));
|
||||
let float = Descriptor::new("RGBC")
|
||||
.with("redFloat", Value::Double(0.25))
|
||||
.with("greenFloat", Value::Double(0.5))
|
||||
.with("blueFloat", Value::Double(1.0));
|
||||
assert_eq!(solid_color(&versioned(Descriptor::new("null").with("Clr ", Value::Descriptor(float)))).unwrap(), Some([0.25, 0.5, 1.0]));
|
||||
let gray = Descriptor::new("Grsc").with("Gry ", Value::Double(25.0));
|
||||
assert_eq!(solid_color(&versioned(Descriptor::new("null").with("Clr ", Value::Descriptor(gray)))).unwrap(), Some([0.75; 3]));
|
||||
// A colour model that is not RGB or gray is no colour; a cut block is an error.
|
||||
let lab = Descriptor::new("LbCl").with("Lmnc", Value::Double(50.0));
|
||||
assert_eq!(solid_color(&versioned(Descriptor::new("null").with("Clr ", Value::Descriptor(lab)))).unwrap(), None);
|
||||
assert!(solid_color(&soco[..soco.len() - 1]).is_err());
|
||||
assert_eq!(layer(vec![(*b"SoCo", vec![0; 5]), (*b"lfx2", vec![0; 5])]).solid_color(), None);
|
||||
assert_eq!(layer(vec![(*b"lfx2", vec![0; 5])]).effects(), None);
|
||||
}
|
||||
}
|
||||
@@ -65,28 +65,24 @@ impl<'a> Rd<'a> {
|
||||
let b = self.take(2)?;
|
||||
Ok(u16::from_be_bytes([b[0], b[1]]))
|
||||
}
|
||||
/// The next big-endian `u32`.
|
||||
fn u32(&mut self) -> Result<u32> {
|
||||
let b = self.take(4)?;
|
||||
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
/// Bytes left to read.
|
||||
fn remaining(&self) -> usize {
|
||||
self.b.len().saturating_sub(self.p)
|
||||
}
|
||||
}
|
||||
|
||||
fn text(d: &Descriptor, key: &str) -> String {
|
||||
match d.get(key) {
|
||||
Some(Value::Text(t)) => t.to_string_lossy(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One style from an ASL body: its identity descriptor, then the style itself.
|
||||
fn read_style(body: &[u8]) -> Result<AslStyle> {
|
||||
let (identity, used) = VersionedDescriptor::parse_prefix(body)?;
|
||||
let rest = body.get(used..).ok_or(PsdError::UnexpectedEof { offset: used, needed: 4 })?;
|
||||
let (style, _) = VersionedDescriptor::parse_prefix(rest)?;
|
||||
let id = &identity.descriptor;
|
||||
Ok(AslStyle { name: text(id, "Nm "), id: text(id, "Idnt"), style: style.descriptor })
|
||||
Ok(AslStyle { name: id.get_text("Nm ").unwrap_or_default(), id: id.get_text("Idnt").unwrap_or_default(), style: style.descriptor })
|
||||
}
|
||||
|
||||
/// Parses an `.asl` style library.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
use crate::error::{PsdError, Result};
|
||||
use crate::io::{Reader, WriteExt, read_unicode_units, write_unicode_units};
|
||||
|
||||
mod lookup;
|
||||
|
||||
/// Maximum nesting depth accepted when parsing (fuzz safety).
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
@@ -229,6 +231,18 @@ pub enum Value {
|
||||
ObjectArray(ObjectArray),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// The value as a number: a double, a unit float, an integer or a large integer.
|
||||
pub fn as_number(&self) -> Option<f64> {
|
||||
match self {
|
||||
Value::Double(v) | Value::UnitFloat { value: v, .. } => Some(*v),
|
||||
Value::Integer(v) => Some(f64::from(*v)),
|
||||
Value::LargeInteger(v) => Some(*v as f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ObAr` object array. Undocumented in the Adobe spec; the layout follows
|
||||
/// ag-psd (MIT): a u32 (observed as the element count / version 16) followed
|
||||
/// by a descriptor-shaped body whose values are usually `UnFl`.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::{Descriptor, Value};
|
||||
|
||||
impl Descriptor {
|
||||
/// The boolean at `key`.
|
||||
pub fn get_bool(&self, key: &str) -> Option<bool> {
|
||||
match self.get(key)? {
|
||||
Value::Boolean(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The number at `key`, from any numeric value type.
|
||||
pub fn get_number(&self, key: &str) -> Option<f64> {
|
||||
match self.get(key)? {
|
||||
Value::Double(v) => Some(*v),
|
||||
Value::UnitFloat { value, .. } => Some(*value),
|
||||
Value::Integer(v) => Some(f64::from(*v)),
|
||||
Value::LargeInteger(v) => Some(*v as f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text at `key`.
|
||||
pub fn get_text(&self, key: &str) -> Option<String> {
|
||||
match self.get(key)? {
|
||||
Value::Text(t) => Some(t.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The enumerated value at `key`.
|
||||
pub fn get_enum(&self, key: &str) -> Option<String> {
|
||||
match self.get(key)? {
|
||||
Value::Enumerated { value, .. } => Some(String::from_utf8_lossy(value.as_bytes()).into_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The object at `key`, plain or global.
|
||||
pub fn get_object(&self, key: &str) -> Option<&Descriptor> {
|
||||
match self.get(key)? {
|
||||
Value::Descriptor(o) | Value::GlobalObject(o) => Some(o),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The numbers at `key`: the values of a unit float array, or the numbers of a list (items
|
||||
/// that are not numbers are left out). Empty when there is neither.
|
||||
pub fn get_numbers(&self, key: &str) -> Vec<f64> {
|
||||
match self.get(key) {
|
||||
Some(Value::UnitFloats { values, .. }) => values.clone(),
|
||||
Some(Value::List(l)) => l.iter().filter_map(Value::as_number).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The raw data (`tdta`) at `key`.
|
||||
pub fn get_raw_data(&self, key: &str) -> Option<&[u8]> {
|
||||
match self.get(key)? {
|
||||
Value::RawData(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The colour object at `key` as RGB in 0..1: an `RGBC` object with `Rd ` / `Grn ` / `Bl `
|
||||
/// in 0..255 or with `redFloat` / `greenFloat` / `blueFloat` in 0..1, or a gray (`Gry `, in
|
||||
/// percent of ink). Other colour models are `None`.
|
||||
pub fn get_color(&self, key: &str) -> Option<[f64; 3]> {
|
||||
let c = self.get_object(key)?;
|
||||
if let (Some(r), Some(g), Some(b)) = (c.get_number("Rd "), c.get_number("Grn "), c.get_number("Bl ")) {
|
||||
return Some([r / 255.0, g / 255.0, b / 255.0]);
|
||||
}
|
||||
if let (Some(r), Some(g), Some(b)) = (c.get_number("redFloat"), c.get_number("greenFloat"), c.get_number("blueFloat")) {
|
||||
return Some([r, g, b]);
|
||||
}
|
||||
c.get_number("Gry ").map(|g| [1.0 - g / 100.0; 3])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{Id, UnicodeString};
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Descriptor {
|
||||
Descriptor::new("null")
|
||||
.with("enab", Value::Boolean(true))
|
||||
.with("doub", Value::Double(0.25))
|
||||
.with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 75.5 })
|
||||
.with("long", Value::Integer(-5))
|
||||
.with("comp", Value::LargeInteger(1 << 40))
|
||||
.with("Nm ", Value::Text(UnicodeString::new("Café")))
|
||||
.with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Nrml") })
|
||||
.with("Objc", Value::Descriptor(Descriptor::new("Clr ").with("Rd ", Value::Double(1.0))))
|
||||
.with("GlbO", Value::GlobalObject(Descriptor::new("Grsc")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_lookup_reads_its_own_value_type() {
|
||||
let d = sample();
|
||||
assert_eq!(d.get_bool("enab"), Some(true));
|
||||
assert_eq!(d.get_number("doub"), Some(0.25));
|
||||
assert_eq!(d.get_number("Opct"), Some(75.5));
|
||||
assert_eq!(d.get_number("long"), Some(-5.0));
|
||||
assert_eq!(d.get_number("comp"), Some((1u64 << 40) as f64));
|
||||
assert_eq!(d.get_text("Nm ").as_deref(), Some("Café"));
|
||||
assert_eq!(d.get_enum("Md ").as_deref(), Some("Nrml"));
|
||||
assert_eq!(d.get_object("Objc").and_then(|o| o.get_number("Rd ")), Some(1.0));
|
||||
assert!(d.get_object("GlbO").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookups_of_other_types_or_missing_keys_are_none() {
|
||||
let d = sample();
|
||||
assert_eq!(d.get_bool("doub"), None);
|
||||
assert_eq!(d.get_number("enab"), None);
|
||||
assert_eq!(d.get_text("long"), None);
|
||||
assert_eq!(d.get_enum("Nm "), None);
|
||||
assert!(d.get_object("Md ").is_none());
|
||||
for key in ["none", " "] {
|
||||
assert_eq!((d.get_bool(key), d.get_number(key), d.get_text(key), d.get_enum(key)), (None, None, None, None));
|
||||
assert!(d.get_object(key).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-23
@@ -58,18 +58,6 @@ pub struct GrdGradient {
|
||||
pub opacity: Vec<(f32, f32, f32)>,
|
||||
}
|
||||
|
||||
/// The raw number behind a key. It is deliberately *not* filtered: every caller passes the result
|
||||
/// through `finite`, so filtering here as well would give two guards with two different answers to
|
||||
/// the same input, and mutation testing showed the second one was unreachable.
|
||||
fn num(d: &Descriptor, k: &str) -> Option<f64> {
|
||||
match d.get(k)? {
|
||||
Value::Double(v) => Some(*v),
|
||||
Value::UnitFloat { value, .. } => Some(*value),
|
||||
Value::Integer(v) => Some(f64::from(*v)),
|
||||
Value::LargeInteger(v) => Some(*v as f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The single guard every number passes through on its way to an `f32`. Only a NaN is truly
|
||||
/// without a value, so only a NaN returns `None`; the caller then falls back to the default its
|
||||
/// own field documents (opacity opaque, a midpoint centred, a channel zero).
|
||||
@@ -85,9 +73,11 @@ fn finite(v: f64) -> Option<f32> {
|
||||
}
|
||||
Some(v.clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32)
|
||||
}
|
||||
/// The finite `f32` at `k`, or 0.
|
||||
fn f(d: &Descriptor, k: &str) -> f32 {
|
||||
finite(num(d, k).unwrap_or(0.0)).unwrap_or(0.0)
|
||||
finite(d.get_number(k).unwrap_or(0.0)).unwrap_or(0.0)
|
||||
}
|
||||
/// `v` clamped to 0..=1.
|
||||
fn clamp01(v: f32) -> f32 {
|
||||
v.clamp(0.0, 1.0)
|
||||
}
|
||||
@@ -99,12 +89,7 @@ fn hue(h: f32) -> f32 {
|
||||
if (0.0..=360.0).contains(&h) { h } else { h.rem_euclid(360.0) }
|
||||
}
|
||||
|
||||
fn obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> {
|
||||
match d.get(k)? {
|
||||
Value::Descriptor(o) | Value::GlobalObject(o) => Some(o),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The list at `k`, empty when absent.
|
||||
fn list<'a>(d: &'a Descriptor, k: &str) -> &'a [Value] {
|
||||
match d.get(k) {
|
||||
Some(Value::List(v)) => v,
|
||||
@@ -115,6 +100,7 @@ fn enum_is(d: &Descriptor, k: &str, v: &str) -> bool {
|
||||
matches!(d.get(k), Some(Value::Enumerated { value, .. }) if value.is(v))
|
||||
}
|
||||
|
||||
/// A gradient stop colour in its recorded colour model.
|
||||
fn color(c: &Descriptor) -> Option<GrdColor> {
|
||||
let id = c.class_id.as_bytes();
|
||||
Some(match id {
|
||||
@@ -135,13 +121,16 @@ fn color(c: &Descriptor) -> Option<GrdColor> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A stop's location as a 0..1 fraction.
|
||||
fn location(d: &Descriptor) -> f32 {
|
||||
(f(d, "Lctn") / 4096.0).clamp(0.0, 1.0)
|
||||
}
|
||||
/// A stop's midpoint as a 0..1 fraction, 0.5 when absent.
|
||||
fn midpoint(d: &Descriptor) -> f32 {
|
||||
finite(num(d, "Mdpn").unwrap_or(50.0) / 100.0).unwrap_or(0.5).clamp(0.0, 1.0)
|
||||
finite(d.get_number("Mdpn").unwrap_or(50.0) / 100.0).unwrap_or(0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// One gradient from its descriptor.
|
||||
fn gradient(g: &Descriptor) -> GrdGradient {
|
||||
let name = match g.get("Nm ") {
|
||||
Some(Value::Text(t)) => t.to_string_lossy(),
|
||||
@@ -155,7 +144,7 @@ fn gradient(g: &Descriptor) -> GrdGradient {
|
||||
} else if enum_is(s, "Type", "BckC") {
|
||||
Some(GrdColor::Background)
|
||||
} else {
|
||||
obj(s, "Clr ").and_then(color)
|
||||
s.get_object("Clr ").and_then(color)
|
||||
};
|
||||
if let Some(color) = c {
|
||||
out.stops.push(GrdStop { location: location(s), midpoint: midpoint(s), color });
|
||||
@@ -163,7 +152,7 @@ fn gradient(g: &Descriptor) -> GrdGradient {
|
||||
}
|
||||
for v in list(g, "Trns").iter().take(MAX_STOPS) {
|
||||
let Value::Descriptor(s) = v else { continue };
|
||||
out.opacity.push((location(s), finite(num(s, "Opct").unwrap_or(100.0) / 100.0).unwrap_or(1.0).clamp(0.0, 1.0), midpoint(s)));
|
||||
out.opacity.push((location(s), finite(s.get_number("Opct").unwrap_or(100.0) / 100.0).unwrap_or(1.0).clamp(0.0, 1.0), midpoint(s)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -185,7 +174,7 @@ pub fn parse(data: &[u8]) -> Result<Vec<GrdGradient>> {
|
||||
.iter()
|
||||
.take(MAX_GRADIENTS)
|
||||
.filter_map(|v| match v {
|
||||
Value::Descriptor(d) => obj(d, "Grad").map(gradient),
|
||||
Value::Descriptor(d) => d.get_object("Grad").map(gradient),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
//! EXIF, XMP) from the start of a file, without the layers or pixels.
|
||||
//! * [`descriptor`] parses and writes ActionDescriptors.
|
||||
//! * [`engine_data`] parses and writes the text engine document of type layers.
|
||||
//! * [`text`], [`smart`] and [`adjust`] are read-only views of the blocks a viewer needs: a type
|
||||
//! layer's text and style, a smart object's placement, warp and embedded file, an adjustment
|
||||
//! layer's settings, a fill layer's colour and the layer effects.
|
||||
//!
|
||||
//! Implemented clean-room from Adobe's public "Photoshop File Formats
|
||||
//! Specification". Undocumented details that follow MIT-licensed psd-tools
|
||||
@@ -46,6 +49,7 @@
|
||||
pub mod abr;
|
||||
pub mod aco;
|
||||
pub mod act;
|
||||
pub mod adjust;
|
||||
pub mod animation;
|
||||
pub mod ase;
|
||||
pub mod asl;
|
||||
@@ -78,12 +82,14 @@ pub mod resources;
|
||||
pub mod rows;
|
||||
pub mod shc;
|
||||
pub mod slices;
|
||||
pub mod smart;
|
||||
pub mod source;
|
||||
pub mod tagged;
|
||||
#[cfg(any(test, feature = "testgen"))]
|
||||
// Test-data generator (tests and fuzz seeds only): failing loudly is the point.
|
||||
#[allow(clippy::expect_used)]
|
||||
pub mod testgen;
|
||||
pub mod text;
|
||||
pub mod tiff;
|
||||
pub mod tree;
|
||||
|
||||
|
||||
+18
-24
@@ -108,20 +108,7 @@ fn d_u32(d: &Descriptor, key: &str) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the boolean at `key` is true.
|
||||
fn d_bool(d: &Descriptor, key: &str) -> bool {
|
||||
matches!(d.get(key), Some(Value::Boolean(true)))
|
||||
}
|
||||
@@ -141,6 +128,7 @@ fn origin_from(s: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slice type code a slice type name stands for.
|
||||
fn kind_from(s: &str) -> u32 {
|
||||
match s {
|
||||
"noImage" => 0,
|
||||
@@ -149,9 +137,10 @@ fn kind_from(s: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A slice record from its descriptor.
|
||||
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")) {
|
||||
let color = match (d.get_enum("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 };
|
||||
@@ -162,18 +151,18 @@ fn slice_from_descriptor(d: &Descriptor) -> SliceRecord {
|
||||
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)),
|
||||
origin: d.get_enum("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)),
|
||||
name: d.get_text("Nm ").unwrap_or_default(),
|
||||
kind: d.get_enum("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"),
|
||||
url: d.get_text("url").unwrap_or_default(),
|
||||
target: d.get_text("null").unwrap_or_default(),
|
||||
message: d.get_text("Msge").unwrap_or_default(),
|
||||
alt: d.get_text("altTag").unwrap_or_default(),
|
||||
cell_text_is_html: d_bool(d, "cellTextIsHTML"),
|
||||
cell_text: d_text(d, "cellText"),
|
||||
cell_text: d.get_text("cellText").unwrap_or_default(),
|
||||
horizontal_align: 0,
|
||||
vertical_align: 0,
|
||||
color,
|
||||
@@ -220,7 +209,12 @@ impl SlicesResource {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(SlicesResource { version, bounds: d_bounds(d, "bounds").unwrap_or_default(), group_name: d_text(d, "baseName"), slices })
|
||||
Ok(SlicesResource {
|
||||
version,
|
||||
bounds: d_bounds(d, "bounds").unwrap_or_default(),
|
||||
group_name: d.get_text("baseName").unwrap_or_default(),
|
||||
slices,
|
||||
})
|
||||
}
|
||||
v => Err(PsdError::invalid(format!("unsupported slices resource version {v}"))),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
//! Smart objects: the placed-layer blocks (`SoLd`, `SoLE`, `PlLd`), the files they show
|
||||
//! (`lnk2`, `lnkD`, `lnk3`, `lnkE`), and the geometry of a placement.
|
||||
//!
|
||||
//! A placed layer names a linked file by its unique id and says where the file's corners sit in
|
||||
//! the document: a quad, which is a projective map of the content rectangle when it is not a
|
||||
//! parallelogram, and optionally a warp (`warp` in `SoLd`): one of the named styles with bend
|
||||
//! and horizontal / vertical distortion, or a custom envelope whose quilt mesh is a grid of
|
||||
//! bicubic Bézier patches.
|
||||
//!
|
||||
//! The custom mesh is evaluated exactly (bicubic Bézier patches over `meshPoints`, split by
|
||||
//! `quiltSliceX` / `quiltSliceY`). Photoshop does not publish the curves of its named styles;
|
||||
//! the styles here are clean-room geometric definitions of what each name describes (Arc,
|
||||
//! Arch, Bulge, Shell, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist), matched by
|
||||
//! behaviour, not numerically.
|
||||
//!
|
||||
//! The blocks themselves stay raw in the model ([`crate::TaggedBlock`]) and write back
|
||||
//! unchanged; these are read-only views. Came from EffectCraft's `effectcraft-psd`.
|
||||
|
||||
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::layer::LayerRecord;
|
||||
use crate::tagged::TaggedBlock;
|
||||
|
||||
/// The most pixels a baked smart object may have: 2^24 (256 MiB as float RGBA). The size comes
|
||||
/// from the file's transform, which is untrusted.
|
||||
pub const MAX_BAKE_PIXELS: u64 = 1 << 24;
|
||||
|
||||
/// A placed layer's warp.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Warp {
|
||||
/// `warpNone`, `warpArc`, `warpArcLower`, `warpArcUpper`, `warpArch`, `warpBulge`,
|
||||
/// `warpShellLower`, `warpShellUpper`, `warpFlag`, `warpWave`, `warpFish`, `warpRise`,
|
||||
/// `warpFisheye`, `warpInflate`, `warpSqueeze`, `warpTwist`, `warpCustom`.
|
||||
pub style: String,
|
||||
/// Bend, −100..100 (%).
|
||||
pub bend: f64,
|
||||
/// Horizontal distortion, −100..100 (%).
|
||||
pub horizontal: f64,
|
||||
/// Vertical distortion, −100..100 (%).
|
||||
pub vertical: f64,
|
||||
/// The style is applied rotated a quarter turn (`warpRotate` = vertical).
|
||||
pub rotate_vertical: bool,
|
||||
/// The warped area in content pixels: left, top, right, bottom.
|
||||
pub bounds: [f64; 4],
|
||||
/// Custom envelope: patch columns.
|
||||
pub cols: usize,
|
||||
/// Custom envelope: patch rows.
|
||||
pub rows: usize,
|
||||
/// Custom envelope: the (3·cols+1)×(3·rows+1) control points (content pixels, row-major).
|
||||
pub mesh: Vec<[f64; 2]>,
|
||||
}
|
||||
|
||||
/// The eight numbers of a transform entry (`Trnf`, `nonAffineTransform`), when it has exactly eight.
|
||||
fn eight(d: &Descriptor, key: &str) -> Option<[f64; 8]> {
|
||||
d.get_numbers(key).try_into().ok()
|
||||
}
|
||||
|
||||
/// The corner quad (top-left, top-right, bottom-right, bottom-left) from x0 y0 … x3 y3.
|
||||
fn quad_from(t: [f64; 8]) -> [[f64; 2]; 4] {
|
||||
[[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]]
|
||||
}
|
||||
|
||||
impl Warp {
|
||||
/// Reads a `warp` descriptor; `None` for no warp (the identity, or a custom warp without a
|
||||
/// usable mesh).
|
||||
pub fn from_descriptor(d: &Descriptor) -> Option<Warp> {
|
||||
let style = d.get_enum("warpStyle").unwrap_or_else(|| "warpNone".to_string());
|
||||
let side = |b: &Descriptor, k: &str| b.get_number(k).unwrap_or(0.0);
|
||||
let bounds = d.get_object("bounds").map_or([0.0; 4], |b| [side(b, "Left"), side(b, "Top "), side(b, "Rght"), side(b, "Btom")]);
|
||||
let mut w = Warp {
|
||||
bend: d.get_number("warpValue").unwrap_or(0.0),
|
||||
horizontal: d.get_number("warpPerspective").unwrap_or(0.0),
|
||||
vertical: d.get_number("warpPerspectiveOther").unwrap_or(0.0),
|
||||
rotate_vertical: d.get_enum("warpRotate").as_deref() == Some("Vrtc"),
|
||||
bounds,
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
mesh: vec![],
|
||||
style,
|
||||
};
|
||||
if let Some(env) = d.get_object("customEnvelopeWarp") {
|
||||
let cols = env.get_numbers("quiltSliceX").len().saturating_sub(1).max(1);
|
||||
let rows = env.get_numbers("quiltSliceY").len().saturating_sub(1).max(1);
|
||||
if let Some(Value::ObjectArray(pts)) = env.get("meshPoints") {
|
||||
let xs = pts.body.get_numbers("Hrzn");
|
||||
let ys = pts.body.get_numbers("Vrtc");
|
||||
let n = cols.checked_mul(3).zip(rows.checked_mul(3)).and_then(|(c, r)| (c + 1).checked_mul(r + 1));
|
||||
if n.is_some_and(|n| xs.len() == n && ys.len() == n) {
|
||||
w.cols = cols;
|
||||
w.rows = rows;
|
||||
w.mesh = xs.into_iter().zip(ys).map(|(x, y)| [x, y]).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
let identity = w.style == "warpNone" || (w.style != "warpCustom" && w.bend == 0.0 && w.horizontal == 0.0 && w.vertical == 0.0);
|
||||
let custom_ok = w.style != "warpCustom" || !w.mesh.is_empty();
|
||||
(!identity && custom_ok).then_some(w)
|
||||
}
|
||||
|
||||
/// The warp of a content point (content pixels → content pixels).
|
||||
pub fn apply(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
|
||||
let [l, t, r, b] = if self.bounds[2] > self.bounds[0] && self.bounds[3] > self.bounds[1] { self.bounds } else { [0.0, 0.0, w, h] };
|
||||
let (bw, bh) = (r - l, b - t);
|
||||
let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh);
|
||||
if self.style == "warpCustom" {
|
||||
// A mesh that does not match cols × rows (public fields) leaves the point where it is.
|
||||
return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)).unwrap_or(p);
|
||||
}
|
||||
// Styles work in a centred frame (pixels), optionally rotated a quarter turn.
|
||||
let (x, y, fw, fh) = if self.rotate_vertical { (bh * (v - 0.5), -bw * (u - 0.5), bh, bw) } else { (bw * (u - 0.5), bh * (v - 0.5), bw, bh) };
|
||||
let [mut x2, mut y2] = style_map(&self.style, self.bend / 100.0, x, y, fw, fh);
|
||||
// Distortions: a perspective-like taper across the width (horizontal) or height.
|
||||
let (hd, vd) =
|
||||
if self.rotate_vertical { (self.vertical / 100.0, self.horizontal / 100.0) } else { (self.horizontal / 100.0, self.vertical / 100.0) };
|
||||
let s = (x2 / (fw / 2.0)).clamp(-1.5, 1.5);
|
||||
let tt = (y2 / (fh / 2.0)).clamp(-1.5, 1.5);
|
||||
y2 *= 1.0 + hd * s;
|
||||
x2 *= 1.0 + vd * tt;
|
||||
let (ox, oy) = if self.rotate_vertical { (-y2, x2) } else { (x2, y2) };
|
||||
[l + bw / 2.0 + ox, t + bh / 2.0 + oy]
|
||||
}
|
||||
|
||||
/// A point of the custom quilt mesh at (u, v) ∈ [0, 1]².
|
||||
fn mesh_point(&self, u: f64, v: f64) -> Option<[f64; 2]> {
|
||||
let (cols, rows) = (self.cols, self.rows);
|
||||
if cols == 0 || rows == 0 {
|
||||
return None;
|
||||
}
|
||||
let pu = (u * cols as f64).min(cols as f64 - 1e-9);
|
||||
let pv = (v * rows as f64).min(rows as f64 - 1e-9);
|
||||
let (ci, cj) = (pu.floor() as usize, pv.floor() as usize);
|
||||
let (fu, fv) = (pu - ci as f64, pv - cj as f64);
|
||||
let stride = cols.checked_mul(3)?.checked_add(1)?;
|
||||
let bern = |t: f64| {
|
||||
let s = 1.0 - t;
|
||||
[s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t]
|
||||
};
|
||||
let (bu, bv) = (bern(fu), bern(fv));
|
||||
let mut out = [0.0; 2];
|
||||
for (j, bvj) in bv.iter().enumerate() {
|
||||
for (i, bui) in bu.iter().enumerate() {
|
||||
let idx = cj.saturating_mul(3).saturating_add(j).saturating_mul(stride).saturating_add(ci.saturating_mul(3)).saturating_add(i);
|
||||
let q = self.mesh.get(idx)?;
|
||||
let k = bui * bvj;
|
||||
out[0] += q[0] * k;
|
||||
out[1] += q[1] * k;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A named style in a centred frame `fw`×`fh` (y down), bend `b` ∈ [−1, 1].
|
||||
fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] {
|
||||
use std::f64::consts::PI;
|
||||
let (hw, hh) = (fw / 2.0, fh / 2.0);
|
||||
let s = x / hw; // −1..1 across
|
||||
let t = y / hh; // −1..1 down
|
||||
// Concentric arcs: the midline bends through an angle of b·π, rows keep their distance.
|
||||
let arc = |x: f64, y: f64| -> [f64; 2] {
|
||||
let theta = b * PI;
|
||||
if theta.abs() < 1e-9 {
|
||||
return [x, y];
|
||||
}
|
||||
let r = fw / theta;
|
||||
let phi = x / hw * theta / 2.0;
|
||||
let rr = r - y;
|
||||
// Arc centre below the shape for upward bends; keep the midline's centre fixed.
|
||||
[rr * phi.sin(), r - rr * phi.cos()]
|
||||
};
|
||||
let bump = 1.0 - s * s;
|
||||
match style {
|
||||
"warpArc" => arc(x, y),
|
||||
"warpArcLower" => {
|
||||
let a = arc(x, y);
|
||||
let k = (t + 1.0) / 2.0;
|
||||
[x + (a[0] - x) * k, y + (a[1] - y) * k]
|
||||
}
|
||||
"warpArcUpper" => {
|
||||
let a = arc(x, y);
|
||||
let k = (1.0 - t) / 2.0;
|
||||
[x + (a[0] - x) * k, y + (a[1] - y) * k]
|
||||
}
|
||||
"warpArch" => [x, y - b * hh * bump],
|
||||
"warpBulge" => [x, y + t * b * hh * bump],
|
||||
"warpShellLower" => [x * (1.0 - b * 0.25 * (1.0 - t) / 2.0), y + (t + 1.0) / 2.0 * b * hh * bump],
|
||||
"warpShellUpper" => [x * (1.0 - b * 0.25 * (t + 1.0) / 2.0), y - (1.0 - t) / 2.0 * b * hh * bump],
|
||||
"warpFlag" => [x, y - b * hh * 0.5 * (PI * s).sin()],
|
||||
"warpWave" => [x + b * hw * 0.1 * (PI * t).sin(), y - b * hh * 0.5 * (PI * s).sin() * (1.0 - 0.5 * t)],
|
||||
"warpFish" => [x, y + t * b * hh * (PI * (s + 1.0) / 2.0).sin() * (1.0 - 0.5 * (s + 1.0) / 2.0)],
|
||||
"warpRise" => [x, y - b * hh * (PI * s / 2.0).sin()],
|
||||
"warpFisheye" | "warpInflate" => {
|
||||
let k = if style == "warpFisheye" { (1.0 - (s * s + t * t) / 2.0).max(0.0) } else { 1.0 };
|
||||
[x * (1.0 + b * 0.5 * k * (1.0 - t * t)), y * (1.0 + b * 0.5 * k * bump)]
|
||||
}
|
||||
"warpSqueeze" => [x * (1.0 - b * 0.5 * (1.0 - t * t)), y * (1.0 + b * 0.5 * bump)],
|
||||
"warpTwist" => {
|
||||
let r = ((s * s + t * t) / 2.0).sqrt().min(1.0);
|
||||
let a = b * PI / 2.0 * (1.0 - r);
|
||||
let (sn, cs) = a.sin_cos();
|
||||
[x * cs - y * sn, x * sn + y * cs]
|
||||
}
|
||||
_ => [x, y],
|
||||
}
|
||||
}
|
||||
|
||||
/// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`
|
||||
/// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1).
|
||||
pub fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] {
|
||||
let [p0, p1, p2, p3] = q;
|
||||
let sx = p0[0] - p1[0] + p2[0] - p3[0];
|
||||
let sy = p0[1] - p1[1] + p2[1] - p3[1];
|
||||
if sx.abs() < 1e-12 && sy.abs() < 1e-12 {
|
||||
return [[p1[0] - p0[0], p2[0] - p1[0], p0[0]], [p1[1] - p0[1], p2[1] - p1[1], p0[1]], [0.0, 0.0, 1.0]];
|
||||
}
|
||||
let (dx1, dx2) = (p1[0] - p2[0], p3[0] - p2[0]);
|
||||
let (dy1, dy2) = (p1[1] - p2[1], p3[1] - p2[1]);
|
||||
let den = dx1 * dy2 - dx2 * dy1;
|
||||
let g = (sx * dy2 - dx2 * sy) / den;
|
||||
let h = (dx1 * sy - sx * dy1) / den;
|
||||
[[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]]
|
||||
}
|
||||
|
||||
/// The baked size of placed content with document-space bounds `bb` at `k` pixels per document pixel.
|
||||
pub(crate) fn size_for(bb: [f64; 4], k: f64) -> (u32, u32) {
|
||||
(((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32)
|
||||
}
|
||||
|
||||
/// A placed layer (smart object): `SoLd` / `SoLE` (placed layer data) or the older `PlLd`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SmartObject {
|
||||
/// Unique id of the linked file ([`LinkedFile::uuid`]) the layer shows.
|
||||
pub uuid: String,
|
||||
/// Where the content's corners sit in document pixels: top-left, top-right, bottom-right,
|
||||
/// bottom-left. The non-affine transform when the block has one.
|
||||
pub quad: [[f64; 2]; 4],
|
||||
/// The content's size in pixels (`Sz `), when recorded.
|
||||
pub size: Option<[f64; 2]>,
|
||||
/// The placed layer's warp (`warp`), when it has one.
|
||||
pub warp: Option<Warp>,
|
||||
}
|
||||
|
||||
impl SmartObject {
|
||||
/// Reads the data of a `SoLd` / `SoLE` block.
|
||||
pub fn from_placed_layer_data(data: &[u8]) -> Result<Self> {
|
||||
let mut r = Reader::new(data);
|
||||
let _kind = r.array::<4>()?;
|
||||
let _version = r.u32()?;
|
||||
let _descriptor_version = r.u32()?;
|
||||
let d = Descriptor::read(&mut r)?;
|
||||
let uuid = d.get_text("Idnt").unwrap_or_default();
|
||||
let t = eight(&d, "Trnf").unwrap_or([0.0; 8]);
|
||||
// The non-affine transform when present (perspective), else the transform.
|
||||
let t = eight(&d, "nonAffineTransform").unwrap_or(t);
|
||||
let size = d.get_object("Sz ").and_then(|s| Some([s.get_number("Wdth")?, s.get_number("Hght")?]));
|
||||
let warp = d.get_object("warp").and_then(Warp::from_descriptor);
|
||||
Ok(SmartObject { uuid, quad: quad_from(t), size, warp })
|
||||
}
|
||||
|
||||
/// Reads the data of a `PlLd` block. A transform that is not finite is an error.
|
||||
pub fn from_placed_layer(data: &[u8]) -> Result<Self> {
|
||||
let mut r = Reader::new(data);
|
||||
let _kind = r.array::<4>()?;
|
||||
let _version = r.u32()?;
|
||||
let uuid = String::from_utf8_lossy(&read_pascal(&mut r, 1)?).into_owned();
|
||||
// Page, total pages, anti-alias policy, layer type.
|
||||
r.skip(16)?;
|
||||
let mut t = [0.0; 8];
|
||||
for v in &mut t {
|
||||
*v = r.f64()?;
|
||||
if !v.is_finite() {
|
||||
return Err(PsdError::invalid("PlLd: transform is not finite"));
|
||||
}
|
||||
}
|
||||
Ok(SmartObject { uuid, quad: quad_from(t), size: None, warp: None })
|
||||
}
|
||||
|
||||
/// The quad as an affine placement of a `w`×`h` content rectangle: (centre, scale x, scale
|
||||
/// y, rotation in degrees). Perspective and skew are dropped.
|
||||
pub fn placement(&self, w: f64, h: f64) -> ([f64; 2], f64, f64, f64) {
|
||||
let q = self.quad;
|
||||
let centre = [(q[0][0] + q[1][0] + q[2][0] + q[3][0]) / 4.0, (q[0][1] + q[1][1] + q[2][1] + q[3][1]) / 4.0];
|
||||
let top = [q[1][0] - q[0][0], q[1][1] - q[0][1]];
|
||||
let left = [q[3][0] - q[0][0], q[3][1] - q[0][1]];
|
||||
let sx = top[0].hypot(top[1]) / w.max(1e-9);
|
||||
let sy = left[0].hypot(left[1]) / h.max(1e-9);
|
||||
// Mirrored content (left edge turning the other way) keeps a negative y scale.
|
||||
let cross = top[0] * left[1] - top[1] * left[0];
|
||||
(centre, sx, if cross < 0.0 { -sy } else { sy }, top[1].atan2(top[0]).to_degrees())
|
||||
}
|
||||
|
||||
/// Whether the corner quad is a parallelogram (an affine placement).
|
||||
pub fn is_affine(&self) -> bool {
|
||||
let q = self.quad;
|
||||
let ex = q[0][0] + q[2][0] - q[1][0] - q[3][0];
|
||||
let ey = q[0][1] + q[2][1] - q[1][1] - q[3][1];
|
||||
ex.abs() < 1e-3 && ey.abs() < 1e-3
|
||||
}
|
||||
|
||||
/// Whether placing needs baking (a perspective quad or a warp).
|
||||
pub fn needs_bake(&self) -> bool {
|
||||
!self.is_affine() || self.warp.is_some()
|
||||
}
|
||||
|
||||
/// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad.
|
||||
pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
|
||||
self.map_with(&square_to_quad(self.quad), p, w, h)
|
||||
}
|
||||
|
||||
/// [`Self::map`] with the quad's projective matrix already solved (`square_to_quad(self.quad)`).
|
||||
pub(crate) fn map_with(&self, m: &[[f64; 3]; 3], p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
|
||||
let p = match &self.warp {
|
||||
Some(wp) => wp.apply(p, w, h),
|
||||
None => p,
|
||||
};
|
||||
let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9));
|
||||
let z = m[2][0] * u + m[2][1] * v + m[2][2];
|
||||
let z = if z.abs() < 1e-12 { 1e-12 } else { z };
|
||||
[(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z]
|
||||
}
|
||||
|
||||
/// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content.
|
||||
pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] {
|
||||
self.bounds_with(&square_to_quad(self.quad), w, h)
|
||||
}
|
||||
|
||||
pub(crate) fn bounds_with(&self, m: &[[f64; 3]; 3], w: f64, h: f64) -> [f64; 4] {
|
||||
let n = 32;
|
||||
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let p = self.map_with(m, [i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h);
|
||||
b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])];
|
||||
}
|
||||
}
|
||||
[b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()]
|
||||
}
|
||||
|
||||
/// The baked size for a `w`×`h` content at `k` pixels per document pixel.
|
||||
pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) {
|
||||
size_for(self.placed_bounds(w, h), k)
|
||||
}
|
||||
|
||||
/// The scale and size to bake a `w`×`h` content at, wanting `k` pixels per document pixel but
|
||||
/// never more than [`MAX_BAKE_PIXELS`] pixels, nor more than 4× the `doc` size across either
|
||||
/// way: a quad corner far outside the document lowers `k` (possibly below 1) instead of asking
|
||||
/// for a gigantic image. Returns the scale actually used and the size in pixels.
|
||||
pub fn bake_scale(&self, w: f64, h: f64, k: f64, doc: (u32, u32)) -> (f64, (u32, u32)) {
|
||||
let bb = self.placed_bounds(w, h);
|
||||
let (bw, bh) = ((bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0));
|
||||
if !(bw.is_finite() && bh.is_finite() && k.is_finite() && k > 0.0) {
|
||||
return (1.0, (1, 1));
|
||||
}
|
||||
let (max_w, max_h) = ((f64::from(doc.0) * 4.0).max(1.0), (f64::from(doc.1) * 4.0).max(1.0));
|
||||
let capped = k.min(max_w / bw).min(max_h / bh).min((MAX_BAKE_PIXELS as f64 / (bw * bh)).sqrt());
|
||||
// Rounding up could step over a cap, so a lowered scale rounds down.
|
||||
let side = |len: f64| if capped < k { (len * capped).floor() } else { (len * capped).ceil() }.clamp(1.0, 16384.0) as u32;
|
||||
(capped, (side(bw), side(bh)))
|
||||
}
|
||||
}
|
||||
|
||||
impl LayerRecord {
|
||||
/// The layer's smart object placement: `SoLd` or `SoLE` when one reads, else `PlLd`. A
|
||||
/// malformed block counts as absent.
|
||||
pub fn smart_object(&self) -> Option<SmartObject> {
|
||||
let read = |key: &[u8; 4], f: fn(&[u8]) -> Result<SmartObject>| self.block(key).and_then(|b| f(&b.data).ok());
|
||||
read(b"SoLd", SmartObject::from_placed_layer_data)
|
||||
.or_else(|| read(b"SoLE", SmartObject::from_placed_layer_data))
|
||||
.or_else(|| read(b"PlLd", SmartObject::from_placed_layer))
|
||||
}
|
||||
}
|
||||
|
||||
/// A file embedded in (or linked from) the document: one record of a linked layer block
|
||||
/// (`lnk2`, `lnkD`, `lnk3`, `lnkE`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct LinkedFile {
|
||||
/// `liFD` (embedded data), `liFE` (external file) or `liFA` (alias).
|
||||
pub kind: [u8; 4],
|
||||
/// Unique id, which a [`SmartObject::uuid`] names.
|
||||
pub uuid: String,
|
||||
/// Original file name.
|
||||
pub name: String,
|
||||
/// File type code (`8BPS`, `PNGf`, `JPEG`…), may be blank.
|
||||
pub file_type: String,
|
||||
/// Where the embedded bytes are in the block's data (absent for externally linked files).
|
||||
pub data: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
/// `true` for the keys of the linked layer blocks.
|
||||
pub fn is_linked_layer_key(key: &[u8; 4]) -> bool {
|
||||
matches!(key, b"lnk2" | b"lnkD" | b"lnk3" | b"lnkE")
|
||||
}
|
||||
|
||||
/// The records of a linked layer block's data (spec: "Linked Layer"): length, kind, version,
|
||||
/// unique id, original file name, type, creator, data length, optional descriptors, then the
|
||||
/// data. A damaged record ends the list; the records before it are returned.
|
||||
pub fn linked_files(data: &[u8]) -> Vec<LinkedFile> {
|
||||
let mut out = Vec::new();
|
||||
let mut r = Reader::new(data);
|
||||
while r.remaining() >= 8 {
|
||||
let Ok(len) = r.u64() else { break };
|
||||
let start = r.pos();
|
||||
let Ok(item) = r.bytes_u64(len) else { break };
|
||||
match linked_file(item, start) {
|
||||
Ok(f) => out.push(f),
|
||||
Err(_) => break,
|
||||
}
|
||||
// Records are padded to a multiple of 4.
|
||||
let pad = ((4 - len % 4) % 4).min(r.remaining() as u64) as usize;
|
||||
if r.skip(pad).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One record; `at` is where it starts in the block's data.
|
||||
fn linked_file(item: &[u8], at: usize) -> Result<LinkedFile> {
|
||||
let mut r = Reader::new(item);
|
||||
let kind = r.array::<4>()?;
|
||||
let version = r.u32()?;
|
||||
let uuid = String::from_utf8_lossy(&read_pascal(&mut r, 1)?).into_owned();
|
||||
let name = UnicodeString::read(&mut r)?.to_string_lossy();
|
||||
let file_type = String::from_utf8_lossy(&r.array::<4>()?).trim().to_string();
|
||||
let _creator = r.array::<4>()?;
|
||||
let data_len = r.u64()?;
|
||||
if r.u8()? != 0 {
|
||||
let _version = r.u32()?;
|
||||
Descriptor::read(&mut r)?;
|
||||
}
|
||||
let mut out = LinkedFile { kind, uuid, name, file_type, data: None };
|
||||
let embedded = |r: &mut Reader<'_>| -> Result<Range<usize>> {
|
||||
let s = at + r.pos();
|
||||
let n = r.bytes_u64(data_len)?.len();
|
||||
Ok(s..s + n)
|
||||
};
|
||||
match &kind {
|
||||
b"liFD" => out.data = Some(embedded(&mut r)?),
|
||||
b"liFE" => {
|
||||
let _version = r.u32()?;
|
||||
Descriptor::read(&mut r)?;
|
||||
if version > 3 {
|
||||
// Year, month / day / hour / minute, seconds.
|
||||
r.skip(4 + 4 + 8)?;
|
||||
}
|
||||
let _file_size = r.u64()?;
|
||||
if version > 2 && data_len > 0 && r.remaining() as u64 >= data_len {
|
||||
out.data = Some(embedded(&mut r)?);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PsdFile {
|
||||
/// Every linked file of the document's linked layer blocks, each with the block that holds
|
||||
/// it ([`LinkedFile::data`] is a range of that block's data).
|
||||
pub fn linked_files(&self) -> Vec<(&TaggedBlock, LinkedFile)> {
|
||||
self.global_blocks
|
||||
.iter()
|
||||
.filter(|b| is_linked_layer_key(&b.key))
|
||||
.flat_map(|b| linked_files(&b.data).into_iter().map(move |f| (b, f)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The embedded bytes of the linked file `uuid`, when the document embeds it.
|
||||
pub fn linked_data(&self, uuid: &str) -> Option<&[u8]> {
|
||||
self.linked_files().into_iter().find_map(|(b, f)| if f.uuid == uuid { b.data.get(f.data?) } else { None })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::descriptor::{Id, ObjectArray};
|
||||
use crate::io::{WriteExt, write_pascal};
|
||||
|
||||
fn so(quad: [[f64; 2]; 4], warp: Option<Warp>) -> SmartObject {
|
||||
SmartObject { uuid: "x".into(), quad, size: None, warp }
|
||||
}
|
||||
|
||||
fn list(v: &[f64]) -> Value {
|
||||
Value::List(v.iter().map(|&x| Value::Double(x)).collect())
|
||||
}
|
||||
|
||||
/// The data of a `SoLd` block placing `uuid` at `quad`.
|
||||
fn placed_layer_data(uuid: &str, quad: [f64; 8], extra: impl FnOnce(Descriptor) -> Descriptor) -> Vec<u8> {
|
||||
let d = Descriptor::new("null").with("Idnt", Value::Text(UnicodeString::new_nul(uuid))).with("Trnf", list(&quad));
|
||||
let mut out = b"soLD".to_vec();
|
||||
out.put_u32(4);
|
||||
out.put_u32(16);
|
||||
extra(d).write(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// One linked layer record, with its length field and padding.
|
||||
fn linked_record(kind: &[u8; 4], uuid: &str, name: &str, file_type: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut item = kind.to_vec();
|
||||
item.put_u32(2);
|
||||
write_pascal(&mut item, uuid.as_bytes(), 1);
|
||||
UnicodeString::new(name).write(&mut item);
|
||||
item.put(file_type);
|
||||
item.put(b" ");
|
||||
item.put_u64(data.len() as u64);
|
||||
item.put_u8(0);
|
||||
if kind == b"liFD" {
|
||||
item.put(data);
|
||||
} else {
|
||||
item.put_u32(16);
|
||||
Descriptor::new("ExternalFileLink").write(&mut item);
|
||||
item.put_u64(0);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.put_u64(item.len() as u64);
|
||||
out.put(&item);
|
||||
out.resize(out.len().div_ceil(4) * 4, 0);
|
||||
out
|
||||
}
|
||||
|
||||
#[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];
|
||||
let data = placed_layer_data("uuid-1", quad, |d| {
|
||||
let size = Descriptor::new("Pnt ").with("Wdth", Value::Double(20.0)).with("Hght", Value::Double(10.0));
|
||||
let bounds = ["Top ", "Left", "Btom", "Rght"]
|
||||
.iter()
|
||||
.zip([0.0, 0.0, 10.0, 20.0])
|
||||
.fold(Descriptor::new("classFloatRect"), |b, (k, v)| b.with(k, Value::UnitFloat { unit: *b"#Pxl", value: v }));
|
||||
let warp = Descriptor::new("warp")
|
||||
.with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpArc") })
|
||||
.with("warpValue", Value::Double(25.0))
|
||||
.with("warpRotate", Value::Enumerated { type_id: Id::new("Ornt"), value: Id::new("Vrtc") })
|
||||
.with("bounds", Value::Descriptor(bounds));
|
||||
d.with("Sz ", Value::Descriptor(size)).with("warp", Value::Descriptor(warp))
|
||||
});
|
||||
let s = SmartObject::from_placed_layer_data(&data).unwrap();
|
||||
assert_eq!(s.uuid, "uuid-1");
|
||||
assert_eq!(s.quad, [[20.0, 4.0], [40.0, 4.0], [40.0, 44.0], [20.0, 44.0]]);
|
||||
assert_eq!(s.size, Some([20.0, 10.0]));
|
||||
let w = s.warp.clone().unwrap();
|
||||
assert_eq!((w.style.as_str(), w.bend, w.rotate_vertical, w.bounds), ("warpArc", 25.0, true, [0.0, 0.0, 20.0, 10.0]));
|
||||
assert!(s.is_affine() && s.needs_bake());
|
||||
let (centre, sx, sy, rotation) = s.placement(20.0, 10.0);
|
||||
assert_eq!((centre, sx, sy, rotation), ([30.0, 24.0], 1.0, 4.0, 0.0));
|
||||
// Every truncation is an error, never a panic.
|
||||
for cut in 0..data.len() {
|
||||
assert!(SmartObject::from_placed_layer_data(&data[..cut]).is_err(), "cut {cut}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_non_affine_transform_wins_and_an_identity_warp_is_none() {
|
||||
let data = placed_layer_data("p", [0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0], |d| {
|
||||
let none = Descriptor::new("warp").with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpNone") });
|
||||
d.with("nonAffineTransform", list(&[10.0, 10.0, 50.0, 0.0, 50.0, 40.0, 10.0, 30.0])).with("warp", Value::Descriptor(none))
|
||||
});
|
||||
let s = SmartObject::from_placed_layer_data(&data).unwrap();
|
||||
assert_eq!(s.quad, [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]]);
|
||||
assert!(s.warp.is_none() && !s.is_affine());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_envelope_reads_its_quilt_mesh_and_a_wrong_sized_one_is_no_warp() {
|
||||
let envelope = |points: usize| {
|
||||
let unfl = |v: Vec<f64>| Value::UnitFloats { unit: *b"#Pxl", values: v };
|
||||
let body =
|
||||
Descriptor::new("rationalPoint").with("Hrzn", unfl((0..points).map(|i| i as f64).collect())).with("Vrtc", unfl(vec![1.0; points]));
|
||||
let env = Descriptor::new("customEnvelopeWarp")
|
||||
.with("quiltSliceX", unfl(vec![0.0, 10.0, 20.0]))
|
||||
.with("quiltSliceY", unfl(vec![0.0, 10.0]))
|
||||
.with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body }));
|
||||
Descriptor::new("warp")
|
||||
.with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpCustom") })
|
||||
.with("customEnvelopeWarp", Value::Descriptor(env))
|
||||
};
|
||||
// Two columns and one row of patches: 7 × 4 control points.
|
||||
let w = Warp::from_descriptor(&envelope(28)).unwrap();
|
||||
assert_eq!((w.cols, w.rows, w.mesh.len()), (2, 1, 28));
|
||||
assert_eq!(w.mesh[5], [5.0, 1.0]);
|
||||
assert_eq!(Warp::from_descriptor(&envelope(27)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_older_placed_layer_block_gives_the_id_and_quad_and_refuses_a_transform_that_is_not_finite() {
|
||||
let block = |t: [f64; 8]| {
|
||||
let mut out = b"plcL".to_vec();
|
||||
out.put_u32(3);
|
||||
write_pascal(&mut out, b"old-id", 1);
|
||||
for v in [1u32, 1, 16, 2] {
|
||||
out.put_u32(v);
|
||||
}
|
||||
for v in t {
|
||||
out.put(&v.to_be_bytes());
|
||||
}
|
||||
out
|
||||
};
|
||||
let s = SmartObject::from_placed_layer(&block([1.0, 2.0, 3.0, 2.0, 3.0, 4.0, 1.0, 4.0])).unwrap();
|
||||
assert_eq!((s.uuid.as_str(), s.quad), ("old-id", [[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]]));
|
||||
assert!(SmartObject::from_placed_layer(&block([f64::NAN; 8])).is_err());
|
||||
assert!(SmartObject::from_placed_layer(&block([f64::INFINITY; 8])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_prefers_placed_layer_data_and_ignores_a_malformed_block() {
|
||||
let good = placed_layer_data("new", [0.0; 8], |d| d);
|
||||
let mut old = b"plcL".to_vec();
|
||||
old.put_u32(3);
|
||||
write_pascal(&mut old, b"old", 1);
|
||||
old.extend_from_slice(&[0; 16 + 64]);
|
||||
let mut layer = LayerRecord { blocks: vec![TaggedBlock::new(*b"PlLd", old), TaggedBlock::new(*b"SoLd", good)], ..Default::default() };
|
||||
assert_eq!(layer.smart_object().unwrap().uuid, "new");
|
||||
layer.blocks[1].data.truncate(9);
|
||||
assert_eq!(layer.smart_object().unwrap().uuid, "old");
|
||||
layer.blocks.clear();
|
||||
assert!(layer.smart_object().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_files_lists_embedded_and_external_records_and_stops_at_damage() {
|
||||
let mut data = linked_record(b"liFD", "uuid-1", "a.png", b"PNGf", b"PNGDATA");
|
||||
let first = data.len();
|
||||
data.extend(linked_record(b"liFE", "uuid-2", "b.psd", b"8BPS", b""));
|
||||
let files = linked_files(&data);
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(
|
||||
(files[0].kind, files[0].uuid.as_str(), files[0].name.as_str(), files[0].file_type.as_str()),
|
||||
(*b"liFD", "uuid-1", "a.png", "PNGf")
|
||||
);
|
||||
assert_eq!(&data[files[0].data.clone().unwrap()], b"PNGDATA");
|
||||
assert_eq!((files[1].kind, files[1].uuid.as_str(), files[1].data.clone()), (*b"liFE", "uuid-2", None));
|
||||
// A record whose length runs past the block, or that is cut short, ends the list.
|
||||
let second_end = first + 8 + u64::from_be_bytes(data[first..first + 8].try_into().unwrap()) as usize;
|
||||
for cut in first..second_end {
|
||||
assert_eq!(linked_files(&data[..cut]).len(), 1, "cut {cut}");
|
||||
}
|
||||
let mut hostile = data.clone();
|
||||
hostile[..8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert!(linked_files(&hostile).is_empty());
|
||||
// An embedded length larger than the record is damage, not an out-of-range slice.
|
||||
let mut long = linked_record(b"liFD", "u", "n", b"JPEG", b"abc");
|
||||
let at = long.windows(8).rposition(|w| w == 3u64.to_be_bytes()).unwrap();
|
||||
long[at..at + 8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert!(linked_files(&long).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_finds_the_embedded_bytes_of_a_smart_object() {
|
||||
let mut file = crate::testgen::small(crate::Version::Psd, crate::Compression::Raw);
|
||||
file.global_blocks.push(TaggedBlock::new(*b"lnk2", linked_record(b"liFD", "uuid-1", "a.png", b"PNGf", b"PNGDATA")));
|
||||
file.global_blocks.push(TaggedBlock::new(*b"lnkE", linked_record(b"liFE", "uuid-2", "b.psd", b"8BPS", b"")));
|
||||
assert_eq!(file.linked_files().len(), 2);
|
||||
assert_eq!(file.linked_data("uuid-1"), Some(b"PNGDATA".as_slice()));
|
||||
assert_eq!(file.linked_data("uuid-2"), None);
|
||||
assert_eq!(file.linked_data("nope"), None);
|
||||
// The blocks are untouched: the file still writes and reads back.
|
||||
let again = PsdFile::from_bytes(&file.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(again.linked_data("uuid-1"), Some(b"PNGDATA".as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_quad_maps_corners_and_is_projective() {
|
||||
let q = [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]];
|
||||
let s = so(q, None);
|
||||
assert!(!s.is_affine() && s.needs_bake());
|
||||
for (c, want) in [([0.0, 0.0], q[0]), ([20.0, 0.0], q[1]), ([20.0, 10.0], q[2]), ([0.0, 10.0], q[3])] {
|
||||
let p = s.map(c, 20.0, 10.0);
|
||||
assert!((p[0] - want[0]).abs() < 1e-9 && (p[1] - want[1]).abs() < 1e-9, "{c:?} → {p:?}");
|
||||
}
|
||||
// Projective, not bilinear: the content centre is where the diagonals cross.
|
||||
let c = s.map([10.0, 5.0], 20.0, 10.0);
|
||||
assert!((c[0] - 30.0).abs() > 0.5, "{c:?}");
|
||||
let affine = so([[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], None);
|
||||
assert!(affine.is_affine() && !affine.needs_bake());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_mesh_and_styles_warp() {
|
||||
// An identity 4×4 mesh shifted 3 px right in its interior row: interior points move.
|
||||
let mut mesh = vec![];
|
||||
for j in 0..4 {
|
||||
for i in 0..4 {
|
||||
mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 3.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]);
|
||||
}
|
||||
}
|
||||
let w = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() };
|
||||
let p = w.apply([10.0, 5.0], 20.0, 10.0);
|
||||
assert!((p[0] - 12.25).abs() < 1e-9 && (p[1] - 5.0).abs() < 1e-9, "{p:?}");
|
||||
assert_eq!(w.apply([0.0, 0.0], 20.0, 10.0), [0.0, 0.0]);
|
||||
// Arc: the top centre rises, the bottom centre too (concentric arcs); Bulge: top up,
|
||||
// bottom down; Flag keeps the centre.
|
||||
let style = |s: &str| Warp { style: s.into(), bend: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
|
||||
let top = style("warpArc").apply([100.0, 0.0], 200.0, 100.0);
|
||||
let corner = style("warpArc").apply([0.0, 0.0], 200.0, 100.0);
|
||||
assert!(top[1] < corner[1], "arc {top:?} {corner:?}");
|
||||
let b = style("warpBulge");
|
||||
assert!(b.apply([100.0, 0.0], 200.0, 100.0)[1] < 0.0 && b.apply([100.0, 100.0], 200.0, 100.0)[1] > 100.0);
|
||||
assert!((style("warpFlag").apply([100.0, 50.0], 200.0, 100.0)[1] - 50.0).abs() < 1e-9);
|
||||
// Vertical rotation swaps the roles of the axes.
|
||||
let mut v = style("warpBulge");
|
||||
v.rotate_vertical = true;
|
||||
let l = v.apply([0.0, 50.0], 200.0, 100.0);
|
||||
assert!(l[0] < 0.0 && (l[1] - 50.0).abs() < 1e-9, "{l:?}");
|
||||
// Horizontal distortion: the right side grows taller.
|
||||
let d = Warp { style: "warpArc".into(), horizontal: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
|
||||
assert!(d.apply([200.0, 0.0], 200.0, 100.0)[1] < d.apply([0.0, 0.0], 200.0, 100.0)[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_warp_with_a_missing_or_short_mesh_does_not_panic() {
|
||||
let base = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, ..Default::default() };
|
||||
// Empty and too-short meshes leave the point where it is.
|
||||
assert_eq!(base.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]);
|
||||
let short = Warp { mesh: vec![[0.0, 0.0]; 5], ..base.clone() };
|
||||
assert_eq!(short.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]);
|
||||
for (cols, rows) in [(0, 0), (0, 3), (usize::MAX, 1), (1, usize::MAX), (usize::MAX, usize::MAX)] {
|
||||
let w = Warp { cols, rows, ..base.clone() };
|
||||
assert_eq!(w.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absurd_quad_corner_bakes_within_four_times_the_document_and_2_pow_24_pixels() {
|
||||
let doc = (1920u32, 1080u32);
|
||||
for far in [1e4, 1e6, 1e12] {
|
||||
let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [far, 5.0], [far, far], [0.0, 20.0]], size: None, warp: None };
|
||||
assert!(s.needs_bake());
|
||||
// What the importer asks for: the unbounded size is the 16384² clamp, 4 GiB as float RGBA.
|
||||
assert_eq!(s.placed_size(100.0, 100.0, 4.0), (16384, 16384));
|
||||
let (k, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc);
|
||||
assert!(k > 0.0 && k <= 4.0, "{k}");
|
||||
assert!(w <= doc.0 * 4 && h <= doc.1 * 4, "{w}x{h}");
|
||||
assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS, "{w}x{h}");
|
||||
}
|
||||
// A quad inside the document keeps the scale and the size it always had.
|
||||
let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [400.0, 50.0], [400.0, 150.0], [0.0, 200.0]], size: None, warp: None };
|
||||
let (k, size) = s.bake_scale(100.0, 100.0, 2.0, doc);
|
||||
assert_eq!((k, size), (2.0, s.placed_size(100.0, 100.0, 2.0)));
|
||||
// Not-a-number corners stay small too.
|
||||
let s = SmartObject { uuid: "x".into(), quad: [[f64::NAN; 2]; 4], size: None, warp: None };
|
||||
let (_, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc);
|
||||
assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Type layers: the `TySh` block as a read-only view.
|
||||
//!
|
||||
//! Layout (Adobe's specification, "Type tool object setting"): a version, six doubles of
|
||||
//! transform, a text version, a descriptor version and the text descriptor, whose `Txt ` is the
|
||||
//! text, `EngineData` the text engine document ([`crate::engine_data`]) and `bounds` /
|
||||
//! `boundingBox` the text's extent. The warp descriptor and the four bounds that follow are not
|
||||
//! read here. The block stays raw in the model and writes back unchanged. Came from EffectCraft's
|
||||
//! `effectcraft-psd`.
|
||||
|
||||
use crate::descriptor::Descriptor;
|
||||
use crate::engine_data::{self, TextStyle};
|
||||
use crate::error::Result;
|
||||
use crate::io::Reader;
|
||||
use crate::layer::LayerRecord;
|
||||
|
||||
/// What a type layer says: its text, where it sits and how its first style run looks.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TypeLayer {
|
||||
/// The text, with `\n` line breaks and without the line break that ends it.
|
||||
pub text: String,
|
||||
/// Affine transform (xx, xy, yx, yy, tx, ty): the text origin sits at (tx, ty). The identity
|
||||
/// when the file's transform is not finite.
|
||||
pub transform: [f64; 6],
|
||||
/// The first style run, read best effort from the text engine document.
|
||||
pub style: TextStyle,
|
||||
/// Text bounds (left, top, right, bottom) relative to the origin, when stored.
|
||||
pub bounds: Option<[f64; 4]>,
|
||||
/// The text descriptor, for everything else.
|
||||
pub descriptor: Descriptor,
|
||||
}
|
||||
|
||||
impl TypeLayer {
|
||||
/// Reads the data of a `TySh` block.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self> {
|
||||
let mut r = Reader::new(data);
|
||||
let _version = r.u16()?;
|
||||
let mut transform = [0.0; 6];
|
||||
for v in &mut transform {
|
||||
*v = r.f64()?;
|
||||
}
|
||||
// A transform with NaN or infinity is unusable: fall back to the identity.
|
||||
if transform.iter().any(|v| !v.is_finite()) {
|
||||
transform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
}
|
||||
let _text_version = r.u16()?;
|
||||
let _descriptor_version = r.u32()?;
|
||||
let descriptor = Descriptor::read(&mut r)?;
|
||||
let text = descriptor.get_text("Txt ").unwrap_or_default().replace("\r\n", "\n").replace('\r', "\n");
|
||||
let text = text.strip_suffix('\n').map(str::to_string).unwrap_or(text);
|
||||
let style = descriptor.get_raw_data("EngineData").map(|e| engine_data::first_style(&engine_data::parse_lossy(e))).unwrap_or_default();
|
||||
let bounds = descriptor
|
||||
.get_object("bounds")
|
||||
.or_else(|| descriptor.get_object("boundingBox"))
|
||||
.and_then(|o| Some([o.get_number("Left")?, o.get_number("Top ")?, o.get_number("Rght")?, o.get_number("Btom")?]));
|
||||
Ok(TypeLayer { text, transform, style, bounds, descriptor })
|
||||
}
|
||||
}
|
||||
|
||||
impl LayerRecord {
|
||||
/// The layer's type (`TySh`), when it is a type layer. A malformed block counts as absent.
|
||||
pub fn type_layer(&self) -> Option<TypeLayer> {
|
||||
TypeLayer::from_bytes(&self.block(b"TySh")?.data).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::descriptor::{UnicodeString, Value};
|
||||
use crate::io::WriteExt;
|
||||
use crate::tagged::TaggedBlock;
|
||||
|
||||
/// The data of a `TySh` block.
|
||||
fn type_block(transform: [f64; 6], text: &str, engine: Option<Vec<u8>>, bounds_key: Option<&str>) -> Vec<u8> {
|
||||
let mut d = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul(text)));
|
||||
if let Some(e) = engine {
|
||||
d = d.with("EngineData", Value::RawData(e));
|
||||
}
|
||||
if let Some(key) = bounds_key {
|
||||
let b = ["Left", "Top ", "Rght", "Btom"]
|
||||
.iter()
|
||||
.zip([-1.0, -20.0, 80.0, 5.0])
|
||||
.fold(Descriptor::new("bounds"), |b, (k, v)| b.with(k, Value::UnitFloat { unit: *b"#Pnt", value: v }));
|
||||
d = d.with(key, Value::Descriptor(b));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.put_u16(1);
|
||||
for v in transform {
|
||||
out.put_f64(v);
|
||||
}
|
||||
out.put_u16(50);
|
||||
out.put_u32(16);
|
||||
d.write(&mut out);
|
||||
// The warp version, descriptor version and descriptor, and the four bounds that follow.
|
||||
out.put_u16(1);
|
||||
out.put_u32(16);
|
||||
Descriptor::new("warp").write(&mut out);
|
||||
out.extend_from_slice(&[0; 32]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_block_gives_text_transform_style_and_bounds() {
|
||||
let engine = engine_data::minimal("Hello\rWorld", "Arial-BoldMT", 36.0, [1.0, 0.5, 0.0], 2);
|
||||
let data = type_block([1.0, 0.0, 0.0, 1.0, 40.0, 60.0], "Hello\rWorld\r", Some(engine), Some("bounds"));
|
||||
let t = TypeLayer::from_bytes(&data).unwrap();
|
||||
assert_eq!(t.text, "Hello\nWorld");
|
||||
assert_eq!(t.transform, [1.0, 0.0, 0.0, 1.0, 40.0, 60.0]);
|
||||
assert_eq!(
|
||||
(t.style.font.as_deref(), t.style.size, t.style.color, t.style.justification),
|
||||
(Some("Arial-BoldMT"), Some(36.0), Some([1.0, 0.5, 0.0]), Some(2))
|
||||
);
|
||||
assert_eq!(t.bounds, Some([-1.0, -20.0, 80.0, 5.0]));
|
||||
assert!(t.descriptor.get("EngineData").is_some());
|
||||
let layer = LayerRecord { blocks: vec![TaggedBlock::new(*b"TySh", data.clone())], ..Default::default() };
|
||||
assert_eq!(layer.type_layer(), Some(t));
|
||||
// Cut anywhere inside the text descriptor: an error, and the layer reads as no type layer.
|
||||
let descriptor_end = data.len() - 32 - 6 - Descriptor::new("warp").to_bytes().len();
|
||||
for cut in 0..descriptor_end {
|
||||
assert!(TypeLayer::from_bytes(&data[..cut]).is_err(), "cut {cut}");
|
||||
}
|
||||
let cut = LayerRecord { blocks: vec![TaggedBlock::new(*b"TySh", data[..40].to_vec())], ..Default::default() };
|
||||
assert_eq!(cut.type_layer(), None);
|
||||
assert_eq!(LayerRecord::default().type_layer(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_line_breaks_a_bounding_box_and_missing_engine_data_read() {
|
||||
let t = TypeLayer::from_bytes(&type_block([2.0, 0.0, 0.0, 2.0, 0.0, 0.0], "a\r\nb", None, Some("boundingBox"))).unwrap();
|
||||
assert_eq!(t.text, "a\nb");
|
||||
assert_eq!(t.style, TextStyle::default());
|
||||
assert_eq!(t.bounds, Some([-1.0, -20.0, 80.0, 5.0]));
|
||||
assert_eq!(TypeLayer::from_bytes(&type_block([1.0; 6], "x", None, None)).unwrap().bounds, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_that_is_not_finite_becomes_the_identity() {
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let t = TypeLayer::from_bytes(&type_block([1.0, 0.0, bad, 1.0, 5.0, 5.0], "x", None, None)).unwrap();
|
||||
assert_eq!(t.transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_engine_data_still_gives_the_style_it_holds() {
|
||||
let mut engine = engine_data::minimal("x", "Courier", 12.0, [0.0, 0.0, 0.0], 0);
|
||||
let keep = engine.windows(8).position(|w| w == b"/FontSet").unwrap() + 40;
|
||||
engine.truncate(keep.min(engine.len() - 4));
|
||||
let t = TypeLayer::from_bytes(&type_block([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "x", Some(engine), None)).unwrap();
|
||||
assert_eq!(t.style.size, Some(12.0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user