mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 01:26:15 +00:00
craft-psd: engine_data, the text engine document of type layers (#895)
* 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.
This commit is contained in:
@@ -103,6 +103,12 @@ pixels::{samples_u16, samples_f32, u16_to_bytes, f32_to_bytes, unpack_bits, plan
|
||||
descriptor::{Descriptor, VersionedDescriptor, Value, Id, Class, ReferenceItem, UnicodeString, ObjectArray}
|
||||
Descriptor::from_bytes / to_bytes; VersionedDescriptor::from_bytes / to_bytes (version 16)
|
||||
|
||||
// Text engine data (`EngineData` in `TySh`, and the document's `Txt2`)
|
||||
engine_data::parse(&[u8]) -> Result<Value, ParseError>; parse_lossy(&[u8]) -> Value; write(&Value); write_bare(&[(String, Value)])
|
||||
engine_data::Value::{Dict, Array, String, RawString, Int, Real, Bool, Name} // RawString is written back as the same bytes
|
||||
engine_data::first_style(&Value) -> TextStyle // font, size, fill, justification, tracking, leading, faux styles, box text
|
||||
engine_data::minimal(text, font, size, rgb, justification) -> Vec<u8>
|
||||
|
||||
// Builder
|
||||
PsdBuilder::new(w, h).color_mode(..).depth(8|16).version(..).compression(..).resolution(dpi).icc_profile(..)
|
||||
builder.push_layer(LayerSpec) / begin_group(GroupSpec) / end_group()? / composite(PixelData)
|
||||
@@ -149,7 +155,7 @@ allocations for padded channels return an error on failure.
|
||||
### Preserved raw, not yet typed (parsers planned for M8)
|
||||
|
||||
* Layer effects: `lfx2`, `lrFX`, `lmfx`. The descriptors inside can be parsed on demand with `VersionedDescriptor::from_bytes(&block.data[4..])`.
|
||||
* Type layers: `TySh`, `tySh`, and the EngineData inside them.
|
||||
* Type layers: `TySh`, `tySh`. The EngineData inside them has a parser and writer (`engine_data`); the blocks themselves are not typed.
|
||||
* Smart objects: `SoLd`, `PlLd`, `SoLE`, `lnk2`, `lnkD`, `lnk3`.
|
||||
* Vector masks and shapes: `vmsk`, `vsms`, `vogk`, `vscg`.
|
||||
* Adjustment and fill layers: `curv`, `levl`, `hue2`, `SoCo`, `GdFl`, `PtFl`, …, plus `Patt`, `Txt2`, `FMsk`, 3D and video blocks.
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
//! Photoshop's `EngineData`: the PostScript-like text engine document stored in `TySh`.
|
||||
//!
|
||||
//! Syntax (from observation of real files and public notes): dictionaries `<< /Key value … >>`,
|
||||
//! arrays `[ … ]`, strings `( … )` holding UTF-16BE with a `FE FF` byte-order mark and `\`
|
||||
//! escapes, numbers (`12`, `-1.5`, `.5`), `true`/`false`, and `/Name` values.
|
||||
//!
|
||||
//! [`parse`] and [`write`] keep the document as a tree of [`Value`]s; what was parsed is written
|
||||
//! back in Photoshop's layout, and strings stored as plain bytes stay plain bytes.
|
||||
//! [`first_style`] is a typed view of the first style run, for readers that show a type layer
|
||||
//! without editing it; [`parse_lossy`] reads what it can of a damaged document for them.
|
||||
//! Nesting is limited to 64 levels.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// One EngineData value.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Value {
|
||||
/// `<< /Key value … >>`, in file order.
|
||||
Dict(Vec<(String, Value)>),
|
||||
/// `[ … ]`.
|
||||
Array(Vec<Value>),
|
||||
/// A string stored as UTF-16BE after the `FE FF` mark.
|
||||
String(String),
|
||||
/// A string the file stored as plain bytes, without the `FE FF` mark (one char per byte).
|
||||
/// Kept apart from [`Value::String`] so it is written back as the same bytes: Photoshop
|
||||
/// rejects a `Txt2` whose raw strings come back as UTF-16 (photocraft#9091).
|
||||
RawString(String),
|
||||
/// A number written without a fraction or exponent.
|
||||
Int(i64),
|
||||
/// A number written with a fraction or exponent.
|
||||
Real(f64),
|
||||
/// `true` or `false`.
|
||||
Bool(bool),
|
||||
/// `/Name` in value position.
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// The entry `key` of a dictionary.
|
||||
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||
match self {
|
||||
Value::Dict(items) => items.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Item `i` of an array.
|
||||
pub fn index(&self, i: usize) -> Option<&Value> {
|
||||
self.as_array()?.get(i)
|
||||
}
|
||||
/// Follows a path of dictionary keys.
|
||||
pub fn path(&self, keys: &[&str]) -> Option<&Value> {
|
||||
keys.iter().try_fold(self, |v, k| v.get(k))
|
||||
}
|
||||
/// The entry `key` of a dictionary, mutably.
|
||||
pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
|
||||
match self {
|
||||
Value::Dict(items) => items.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Sets (or appends) a dictionary entry.
|
||||
pub fn set(&mut self, key: &str, v: Value) {
|
||||
if let Value::Dict(items) = self {
|
||||
match items.iter_mut().find(|(k, _)| k == key) {
|
||||
Some(e) => e.1 = v,
|
||||
None => items.push((key.to_string(), v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A finite number.
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
Value::Int(i) => Some(*i as f64),
|
||||
// `inf`, `nan` and `1e999` parse as reals but are never a valid style number.
|
||||
Value::Real(r) if r.is_finite() => Some(*r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A number as an integer (a real is truncated).
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
Value::Int(i) => Some(*i),
|
||||
Value::Real(r) => Some(*r as i64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A boolean; an integer counts as `true` when it is not zero.
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
Value::Bool(b) => Some(*b),
|
||||
Value::Int(i) => Some(*i != 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text of a string or a name.
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Value::String(s) | Value::RawString(s) | Value::Name(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The items of an array.
|
||||
pub fn as_array(&self) -> Option<&[Value]> {
|
||||
match self {
|
||||
Value::Array(a) => Some(a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// An empty dictionary.
|
||||
pub fn dict() -> Value {
|
||||
Value::Dict(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an EngineData document did not parse: a message with the byte offset.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParseError(pub String);
|
||||
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "EngineData: {}", self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
struct Parser<'a> {
|
||||
b: &'a [u8],
|
||||
i: usize,
|
||||
/// Best effort: damage ends the value being read instead of failing the document.
|
||||
lenient: bool,
|
||||
}
|
||||
|
||||
/// Bytes that end a bare token or a `/Name`.
|
||||
fn is_delimiter(c: u8) -> bool {
|
||||
matches!(c, b' ' | b'\t' | b'\r' | b'\n' | b'[' | b']' | b'<' | b'>' | b'(' | b'/')
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn ws(&mut self) {
|
||||
while matches!(self.b.get(self.i), Some(b' ' | b'\t' | b'\r' | b'\n' | 0)) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
fn err<T>(&self, m: &str) -> Result<T, ParseError> {
|
||||
Err(ParseError(format!("{m} at byte {}", self.i)))
|
||||
}
|
||||
fn value(&mut self, depth: usize) -> Result<Value, ParseError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return self.err("nesting too deep");
|
||||
}
|
||||
self.ws();
|
||||
match self.b.get(self.i) {
|
||||
None => self.err("unexpected end"),
|
||||
Some(b'<') if self.b.get(self.i + 1) == Some(&b'<') => {
|
||||
self.i += 2;
|
||||
self.dict(depth)
|
||||
}
|
||||
Some(b'[') => {
|
||||
self.i += 1;
|
||||
self.array(depth)
|
||||
}
|
||||
Some(b'(') => {
|
||||
self.i += 1;
|
||||
self.string()
|
||||
}
|
||||
Some(b'/') => Ok(Value::Name(self.name())),
|
||||
Some(_) => self.token(),
|
||||
}
|
||||
}
|
||||
/// The entries after `<<`.
|
||||
fn dict(&mut self, depth: usize) -> Result<Value, ParseError> {
|
||||
let mut items = Vec::new();
|
||||
loop {
|
||||
self.ws();
|
||||
match self.b.get(self.i) {
|
||||
Some(b'>') if self.b.get(self.i + 1) == Some(&b'>') => {
|
||||
self.i += 2;
|
||||
return Ok(Value::Dict(items));
|
||||
}
|
||||
Some(b'/') => {
|
||||
let k = self.name();
|
||||
match self.value(depth + 1) {
|
||||
Ok(v) => items.push((k, v)),
|
||||
Err(_) if self.lenient => return Ok(Value::Dict(items)),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
None if self.lenient => return Ok(Value::Dict(items)),
|
||||
None => return self.err("unterminated dictionary"),
|
||||
// Lenient: skip the byte that is not a key.
|
||||
_ if self.lenient => self.i += 1,
|
||||
_ => return self.err("expected key"),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The items after `[`.
|
||||
fn array(&mut self, depth: usize) -> Result<Value, ParseError> {
|
||||
let mut items = Vec::new();
|
||||
loop {
|
||||
self.ws();
|
||||
match self.b.get(self.i) {
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(items));
|
||||
}
|
||||
None if self.lenient => return Ok(Value::Array(items)),
|
||||
None => return self.err("unterminated array"),
|
||||
_ => match self.value(depth + 1) {
|
||||
Ok(v) => items.push(v),
|
||||
Err(_) if self.lenient => return Ok(Value::Array(items)),
|
||||
Err(e) => return Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The string after `(`.
|
||||
fn string(&mut self) -> Result<Value, ParseError> {
|
||||
let mut raw = Vec::new();
|
||||
loop {
|
||||
match self.b.get(self.i) {
|
||||
None if self.lenient => break,
|
||||
None => return self.err("unterminated string"),
|
||||
Some(b'\\') => {
|
||||
if let Some(&c) = self.b.get(self.i + 1) {
|
||||
raw.push(c);
|
||||
}
|
||||
self.i += 2;
|
||||
}
|
||||
Some(b')') => {
|
||||
self.i += 1;
|
||||
break;
|
||||
}
|
||||
Some(&c) => {
|
||||
raw.push(c);
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(decode_string(&raw))
|
||||
}
|
||||
/// A number, `true` or `false`.
|
||||
fn token(&mut self) -> Result<Value, ParseError> {
|
||||
let start = self.i;
|
||||
while self.b.get(self.i).is_some_and(|&c| !is_delimiter(c)) {
|
||||
self.i += 1;
|
||||
}
|
||||
let tok = self.b.get(start..self.i).and_then(|t| std::str::from_utf8(t).ok()).unwrap_or("");
|
||||
let number = match tok {
|
||||
"true" => return Ok(Value::Bool(true)),
|
||||
"false" => return Ok(Value::Bool(false)),
|
||||
"" => {
|
||||
// A delimiter that starts no value (`>`, `]`), or bytes that are not UTF-8.
|
||||
self.i = self.i.max(start + 1);
|
||||
None
|
||||
}
|
||||
t if t.contains(['.', 'e', 'E']) => t.parse().ok().map(Value::Real),
|
||||
t => t.parse().map(Value::Int).ok().or_else(|| t.parse().ok().map(Value::Real)),
|
||||
};
|
||||
match number {
|
||||
Some(v) => Ok(v),
|
||||
None if self.lenient => Ok(Value::Name(tok.to_string())),
|
||||
None => self.err("bad token"),
|
||||
}
|
||||
}
|
||||
fn name(&mut self) -> String {
|
||||
self.i += 1; // '/'
|
||||
let start = self.i;
|
||||
while self.b.get(self.i).is_some_and(|&c| !is_delimiter(c)) {
|
||||
self.i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(self.b.get(start..self.i).unwrap_or_default()).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_string(raw: &[u8]) -> Value {
|
||||
if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF {
|
||||
let u: Vec<u16> = raw[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
Value::String(String::from_utf16_lossy(&u))
|
||||
} else {
|
||||
Value::RawString(raw.iter().map(|&c| c as char).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an EngineData document (the top-level dictionary).
|
||||
pub fn parse(data: &[u8]) -> Result<Value, ParseError> {
|
||||
let mut p = Parser { b: data, i: 0, lenient: false };
|
||||
p.value(0)
|
||||
}
|
||||
|
||||
/// Parses as much of a damaged EngineData document as can be read, for readers that only show
|
||||
/// the text: a truncated dictionary, array or string keeps what it has, a byte that starts no
|
||||
/// key is skipped, and a token that is no number becomes a [`Value::Name`]. Garbage gives an
|
||||
/// empty dictionary. What [`parse`] accepts comes out the same. The result is not meant to be
|
||||
/// written back.
|
||||
pub fn parse_lossy(data: &[u8]) -> Value {
|
||||
let mut p = Parser { b: data, i: 0, lenient: true };
|
||||
p.value(0).unwrap_or_else(|_| Value::dict())
|
||||
}
|
||||
|
||||
/// Serializes in Photoshop's layout (tab-indented, one key per line).
|
||||
pub fn write(v: &Value) -> Vec<u8> {
|
||||
let mut out = b"\n\n".to_vec();
|
||||
write_value(v, 0, &mut out);
|
||||
out.push(b'\n');
|
||||
out
|
||||
}
|
||||
|
||||
/// Serializes `/key value` pairs without an enclosing dictionary: the layout of the document's
|
||||
/// `Txt2` block, which a reader wraps in `<< >>` to parse.
|
||||
pub fn write_bare(items: &[(String, Value)]) -> Vec<u8> {
|
||||
let mut out = b"\n\n".to_vec();
|
||||
for (k, v) in items {
|
||||
out.push(b'/');
|
||||
out.extend_from_slice(k.as_bytes());
|
||||
if is_container(v) {
|
||||
out.push(b'\n');
|
||||
indent(&mut out, 1);
|
||||
} else {
|
||||
out.push(b' ');
|
||||
}
|
||||
write_value(v, 1, &mut out);
|
||||
out.push(b'\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn indent(out: &mut Vec<u8>, n: usize) {
|
||||
out.extend(std::iter::repeat_n(b'\t', n));
|
||||
}
|
||||
|
||||
fn is_container(v: &Value) -> bool {
|
||||
matches!(v, Value::Dict(_)) || matches!(v, Value::Array(a) if a.iter().any(|x| matches!(x, Value::Dict(_) | Value::Array(_))))
|
||||
}
|
||||
|
||||
fn write_value(v: &Value, depth: usize, out: &mut Vec<u8>) {
|
||||
match v {
|
||||
Value::Dict(items) => {
|
||||
out.extend_from_slice(b"<<\n");
|
||||
for (k, v) in items {
|
||||
indent(out, depth + 1);
|
||||
out.push(b'/');
|
||||
out.extend_from_slice(k.as_bytes());
|
||||
if is_container(v) {
|
||||
out.push(b'\n');
|
||||
indent(out, depth + 1);
|
||||
} else {
|
||||
out.push(b' ');
|
||||
}
|
||||
write_value(v, depth + 1, out);
|
||||
out.push(b'\n');
|
||||
}
|
||||
indent(out, depth);
|
||||
out.extend_from_slice(b">>");
|
||||
}
|
||||
Value::Array(items) if is_container(v) => {
|
||||
out.extend_from_slice(b"[\n");
|
||||
for x in items {
|
||||
indent(out, depth);
|
||||
write_value(x, depth, out);
|
||||
out.push(b'\n');
|
||||
}
|
||||
indent(out, depth);
|
||||
out.push(b']');
|
||||
}
|
||||
Value::Array(items) => {
|
||||
out.push(b'[');
|
||||
for x in items {
|
||||
out.push(b' ');
|
||||
write_value(x, depth, out);
|
||||
}
|
||||
out.extend_from_slice(b" ]");
|
||||
}
|
||||
Value::RawString(s) if s.chars().all(|c| u32::from(c) <= 0xFF) => {
|
||||
out.push(b'(');
|
||||
for c in s.chars() {
|
||||
let c = u32::from(c) as u8;
|
||||
if matches!(c, b'(' | b')' | b'\\') {
|
||||
out.push(b'\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push(b')');
|
||||
}
|
||||
Value::String(s) | Value::RawString(s) => {
|
||||
out.push(b'(');
|
||||
let mut raw = vec![0xFE, 0xFF];
|
||||
for u in s.encode_utf16() {
|
||||
raw.extend_from_slice(&u.to_be_bytes());
|
||||
}
|
||||
for c in raw {
|
||||
if matches!(c, b'(' | b')' | b'\\') {
|
||||
out.push(b'\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push(b')');
|
||||
}
|
||||
Value::Int(i) => {
|
||||
let _ = write!(Wrap(out), "{i}");
|
||||
}
|
||||
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
|
||||
Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
|
||||
Value::Name(n) => {
|
||||
out.push(b'/');
|
||||
out.extend_from_slice(n.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Wrap<'a>(&'a mut Vec<u8>);
|
||||
impl std::fmt::Write for Wrap<'_> {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
self.0.extend_from_slice(s.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop style: `1.0`, `.5`, `-.25`.
|
||||
fn format_real(r: f64) -> String {
|
||||
if !r.is_finite() {
|
||||
return "0.0".into();
|
||||
}
|
||||
if r.fract() == 0.0 && r.abs() < 1e15 {
|
||||
return format!("{r:.1}");
|
||||
}
|
||||
let mut s = format!("{r:.5}");
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
if matches!(s.as_str(), "0." | "-0.") {
|
||||
return "0.0".into();
|
||||
}
|
||||
if s.ends_with('.') {
|
||||
s.push('0');
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("0.") {
|
||||
s = format!(".{rest}");
|
||||
} else if let Some(rest) = s.strip_prefix("-0.") {
|
||||
s = format!("-.{rest}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The character and paragraph attributes of a type layer's first style run: what a reader that
|
||||
/// shows the text in one style needs. Attributes the run leaves out come from the document's
|
||||
/// first (normal) style sheet.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
/// PostScript name of the font.
|
||||
pub font: Option<String>,
|
||||
/// Font size in points, at most 10 000.
|
||||
pub size: Option<f64>,
|
||||
/// Fill colour, RGB in 0..1.
|
||||
pub color: Option<[f64; 3]>,
|
||||
/// 0 left, 1 right, 2 centre, 3 and above the justified variants.
|
||||
pub justification: Option<u8>,
|
||||
/// Tracking in 1/1000 em, within ±100 000.
|
||||
pub tracking: Option<f64>,
|
||||
/// Leading in points, within ±100 000; `None` for automatic leading.
|
||||
pub leading: Option<f64>,
|
||||
/// Faux bold.
|
||||
pub faux_bold: bool,
|
||||
/// Faux italic.
|
||||
pub faux_italic: bool,
|
||||
/// Paragraph (box) text rather than point text.
|
||||
pub box_text: bool,
|
||||
}
|
||||
|
||||
/// Upper bound for a font size in points (Photoshop's own limit is 1296 pt).
|
||||
const MAX_FONT_SIZE: f64 = 10_000.0;
|
||||
/// Bound for tracking (1/1000 em) and leading (points).
|
||||
const MAX_SPACING: f64 = 100_000.0;
|
||||
|
||||
/// The first style run of a parsed EngineData document (see [`TextStyle`]). Missing and
|
||||
/// non-finite values are `None`; sizes and spacings are clamped to sane bounds.
|
||||
pub fn first_style(ed: &Value) -> TextStyle {
|
||||
let engine = ed.get("EngineDict").unwrap_or(ed);
|
||||
let resources = ed.get("ResourceDict").or_else(|| ed.get("DocumentResources"));
|
||||
let run = engine.path(&["StyleRun", "RunArray"]).and_then(|a| a.index(0)).and_then(|r| r.path(&["StyleSheet", "StyleSheetData"]));
|
||||
let normal = resources.and_then(|r| r.get("StyleSheetSet")).and_then(|a| a.index(0)).and_then(|s| s.get("StyleSheetData"));
|
||||
let attr = |k: &str| run.and_then(|r| r.get(k)).or_else(|| normal.and_then(|n| n.get(k)));
|
||||
let num = |k: &str| attr(k).and_then(Value::as_f64);
|
||||
let font = num("Font")
|
||||
.and_then(|i| resources?.get("FontSet")?.index(i as usize)?.get("Name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.trim_end_matches('\0').to_string());
|
||||
let color = attr("FillColor").and_then(|c| c.get("Values")).and_then(Value::as_array).and_then(|a| match a {
|
||||
[_, r, g, b, ..] => Some([r.as_f64()?, g.as_f64()?, b.as_f64()?]),
|
||||
_ => None,
|
||||
});
|
||||
let para = engine.path(&["ParagraphRun", "RunArray"]).and_then(|a| a.index(0)).and_then(|r| r.path(&["ParagraphSheet", "Properties"]));
|
||||
let para_normal = resources.and_then(|r| r.get("ParagraphSheetSet")).and_then(|a| a.index(0)).and_then(|s| s.get("Properties"));
|
||||
let justification = para
|
||||
.and_then(|p| p.get("Justification"))
|
||||
.or_else(|| para_normal.and_then(|p| p.get("Justification")))
|
||||
.and_then(Value::as_f64)
|
||||
.map(|n| n as u8);
|
||||
let auto_leading = attr("AutoLeading").is_none_or(|v| *v != Value::Bool(false));
|
||||
let shape = engine.path(&["Rendered", "Shapes", "Children"]).and_then(|a| a.index(0)).and_then(|s| s.get("ShapeType"));
|
||||
TextStyle {
|
||||
font,
|
||||
size: num("FontSize").filter(|s| *s > 0.0).map(|s| s.min(MAX_FONT_SIZE)),
|
||||
color,
|
||||
justification,
|
||||
tracking: num("Tracking").map(|t| t.clamp(-MAX_SPACING, MAX_SPACING)),
|
||||
leading: if auto_leading { None } else { num("Leading").map(|l| l.clamp(-MAX_SPACING, MAX_SPACING)) },
|
||||
faux_bold: attr("FauxBold") == Some(&Value::Bool(true)),
|
||||
faux_italic: attr("FauxItalic") == Some(&Value::Bool(true)),
|
||||
box_text: shape.and_then(Value::as_f64) == Some(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal EngineData document for a type layer with one style run: the text, one font, its
|
||||
/// size in points, an RGB fill in 0..1 and the justification ([`TextStyle::justification`]).
|
||||
pub fn minimal(text: &str, font: &str, size: f64, color: [f64; 3], justification: u8) -> Vec<u8> {
|
||||
let dict = |items: Vec<(&str, Value)>| Value::Dict(items.into_iter().map(|(k, v)| (k.to_string(), v)).collect());
|
||||
let run = |sheet: Value| dict(vec![("RunArray", Value::Array(vec![sheet]))]);
|
||||
let fill = Value::Array(vec![Value::Real(1.0), Value::Real(color[0]), Value::Real(color[1]), Value::Real(color[2])]);
|
||||
let style = dict(vec![
|
||||
("Font", Value::Int(0)),
|
||||
("FontSize", Value::Real(size)),
|
||||
("FillColor", dict(vec![("Type", Value::Int(1)), ("Values", fill)])),
|
||||
("AutoLeading", Value::Bool(true)),
|
||||
]);
|
||||
let paragraph = dict(vec![("Properties", dict(vec![("Justification", Value::Int(i64::from(justification)))]))]);
|
||||
let engine = dict(vec![
|
||||
("Editor", dict(vec![("Text", Value::String(format!("{text}\r")))])),
|
||||
("ParagraphRun", run(dict(vec![("ParagraphSheet", paragraph)]))),
|
||||
("StyleRun", run(dict(vec![("StyleSheet", dict(vec![("StyleSheetData", style)]))]))),
|
||||
]);
|
||||
let fonts = Value::Array(vec![dict(vec![("Name", Value::String(font.to_string())), ("Type", Value::Int(0))])]);
|
||||
write(&dict(vec![("EngineDict", engine), ("ResourceDict", dict(vec![("FontSet", fonts)]))]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &[u8] = b"\n\n<<\n\t/EngineDict\n\t<<\n\t\t/Editor\n\t\t<<\n\t\t\t/Text (\xFE\xFF\x00H\x00i\x00\\)\x00\r)\n\t\t>>\n\t\t/Nums [ 1.0 .5 -2 0 ]\n\t\t/Flag true\n\t\t/Kind /Roman\n\t\t/Runs [\n\t\t<<\n\t\t\t/A 1\n\t\t>>\n\t\t]\n\t>>\n>>";
|
||||
|
||||
#[test]
|
||||
fn format_real_never_ends_with_bare_dot() {
|
||||
assert_eq!(format_real(5.000001), "5.0");
|
||||
assert_eq!(format_real(12.000004), "12.0");
|
||||
assert_eq!(format_real(-5.000001), "-5.0");
|
||||
assert_eq!(format_real(1e20), "100000000000000000000.0");
|
||||
assert_eq!(format_real(0.5), ".5");
|
||||
assert_eq!(format_real(-0.25), "-.25");
|
||||
assert_eq!(format_real(1e-9), "0.0");
|
||||
assert_eq!(format_real(3.0), "3.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sample() {
|
||||
let v = parse(SAMPLE).unwrap();
|
||||
assert_eq!(v.path(&["EngineDict", "Editor", "Text"]).and_then(Value::as_str), Some("Hi)\r"));
|
||||
let nums: Vec<f64> = v.path(&["EngineDict", "Nums"]).unwrap().as_array().unwrap().iter().filter_map(Value::as_f64).collect();
|
||||
assert_eq!(nums, [1.0, 0.5, -2.0, 0.0]);
|
||||
assert_eq!(v.path(&["EngineDict", "Flag"]).and_then(Value::as_bool), Some(true));
|
||||
assert_eq!(v.path(&["EngineDict", "Kind"]), Some(&Value::Name("Roman".into())));
|
||||
assert_eq!(v.path(&["EngineDict", "Runs"]).unwrap().as_array().unwrap()[0].get("A").and_then(Value::as_i64), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_strings_are_written_back_as_the_same_bytes() {
|
||||
let v = parse(b"<< /2 (0100\\)) /3 (\xFE\xFF\x00h) >>").unwrap();
|
||||
assert_eq!(v.get("2"), Some(&Value::RawString("0100)".into())));
|
||||
assert_eq!(v.get("2").and_then(Value::as_str), Some("0100)"));
|
||||
assert_eq!(v.get("3"), Some(&Value::String("h".into())));
|
||||
let bytes = write(&v);
|
||||
assert!(bytes.windows(8).any(|w| w == b"(0100\\))"), "{:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_parse_roundtrip() {
|
||||
let v = parse(SAMPLE).unwrap();
|
||||
let bytes = write(&v);
|
||||
assert_eq!(parse(&bytes).unwrap(), v);
|
||||
// Stable: writing twice gives identical bytes.
|
||||
assert_eq!(write(&parse(&bytes).unwrap()), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reals_format_like_photoshop() {
|
||||
assert_eq!(format_real(1.0), "1.0");
|
||||
assert_eq!(format_real(0.5), ".5");
|
||||
assert_eq!(format_real(-0.25), "-.25");
|
||||
assert_eq!(format_real(12.125), "12.125");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_reals_round_trip_as_numbers() {
|
||||
for value in [0.000004, -0.000004] {
|
||||
assert_eq!(format_real(value), "0.0");
|
||||
let source = Value::Dict(vec![(
|
||||
"EngineDict".into(),
|
||||
Value::Dict(vec![("StyleRun".into(), Value::Array(vec![Value::Dict(vec![("BaselineShift".into(), Value::Real(value))])]))]),
|
||||
)]);
|
||||
let encoded = write(&source);
|
||||
let parsed = parse(&encoded).unwrap();
|
||||
let baseline_shift = parsed
|
||||
.path(&["EngineDict", "StyleRun"])
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|runs| runs.first())
|
||||
.and_then(|run| run.get("BaselineShift"))
|
||||
.and_then(Value::as_f64);
|
||||
assert_eq!(baseline_shift, Some(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_style_reads_a_minimal_document() {
|
||||
let b = minimal("Hi (there)", "Arial-BoldMT", 36.0, [1.0, 0.5, 0.0], 2);
|
||||
let v = parse(&b).unwrap();
|
||||
assert_eq!(v.path(&["EngineDict", "Editor", "Text"]).and_then(Value::as_str), Some("Hi (there)\r"));
|
||||
let st = first_style(&v);
|
||||
assert_eq!(st.font.as_deref(), Some("Arial-BoldMT"));
|
||||
assert_eq!(st.size, Some(36.0));
|
||||
assert_eq!(st.color, Some([1.0, 0.5, 0.0]));
|
||||
assert_eq!(st.justification, Some(2));
|
||||
assert_eq!(st.leading, None);
|
||||
assert!(!st.box_text && !st.faux_bold && !st.faux_italic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_style_falls_back_to_the_normal_style_sheet_and_reads_box_text() {
|
||||
let v = parse(
|
||||
b"<< /EngineDict << /StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /FauxBold true >> >> >> ] >> \
|
||||
/Rendered << /Shapes << /Children [ << /ShapeType 1 >> ] >> >> >> \
|
||||
/ResourceDict << /FontSet [ << /Name (A) >> << /Name (B) >> ] \
|
||||
/StyleSheetSet [ << /StyleSheetData << /Font 1 /FontSize 12.0 /Tracking 50 /AutoLeading false /Leading 14.5 >> >> ] \
|
||||
/ParagraphSheetSet [ << /Properties << /Justification 1 >> >> ] >> >>",
|
||||
)
|
||||
.unwrap();
|
||||
let st = first_style(&v);
|
||||
assert_eq!(st.font.as_deref(), Some("B"));
|
||||
assert_eq!((st.size, st.tracking, st.leading), (Some(12.0), Some(50.0), Some(14.5)));
|
||||
assert_eq!(st.justification, Some(1));
|
||||
assert!(st.faux_bold && !st.faux_italic && st.box_text);
|
||||
}
|
||||
|
||||
/// Sizes and spacings are untrusted: non-finite values are dropped and huge ones clamped.
|
||||
#[test]
|
||||
fn first_style_drops_non_finite_numbers_and_clamps_the_rest() {
|
||||
let sheet = |attrs: &str| {
|
||||
let doc = format!("<< /EngineDict << /StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << {attrs} >> >> >> ] >> >> >>");
|
||||
first_style(&parse(doc.as_bytes()).unwrap())
|
||||
};
|
||||
let st = sheet("/FontSize nan /Tracking inf /Leading -inf /AutoLeading false");
|
||||
assert_eq!((st.size, st.tracking, st.leading), (None, None, None));
|
||||
let st = sheet("/FontSize 1e300 /Tracking -1e300 /AutoLeading false /Leading 1e300");
|
||||
assert_eq!((st.size, st.tracking, st.leading), (Some(MAX_FONT_SIZE), Some(-MAX_SPACING), Some(MAX_SPACING)));
|
||||
assert_eq!(sheet("/FontSize -4").size, None);
|
||||
assert_eq!(first_style(&Value::dict()), TextStyle::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_deeper_than_the_limit_is_an_error() {
|
||||
let nested = |n: usize| [vec![b'['; n], vec![b']'; n]].concat();
|
||||
assert!(parse(&nested(MAX_DEPTH)).is_ok());
|
||||
assert!(parse(&nested(MAX_DEPTH + 2)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lossy_keeps_what_a_damaged_document_holds() {
|
||||
// What the strict parser accepts comes out the same.
|
||||
assert_eq!(parse_lossy(SAMPLE), parse(SAMPLE).unwrap());
|
||||
// Truncated inside a string inside an array inside a dictionary.
|
||||
let v = parse_lossy(b"<< /A [ 1 2 << /B (unterminated");
|
||||
assert_eq!(v.get("A").and_then(|a| a.index(1)).and_then(Value::as_i64), Some(2));
|
||||
assert_eq!(v.get("A").and_then(|a| a.index(2)).and_then(|d| d.get("B")).and_then(Value::as_str), Some("unterminated"));
|
||||
// A byte that starts no key is skipped; a bad token is a name, not a number.
|
||||
let v = parse_lossy(b"<< ] /Size 12.0 /Bad 1.2.3 /After true >>");
|
||||
assert_eq!(v.get("Size").and_then(Value::as_f64), Some(12.0));
|
||||
assert_eq!(v.get("Bad"), Some(&Value::Name("1.2.3".into())));
|
||||
assert_eq!(v.get("After"), Some(&Value::Bool(true)));
|
||||
// Garbage and every truncation: no panic, no hang.
|
||||
assert_eq!(parse_lossy(b""), Value::dict());
|
||||
let _ = parse_lossy(b"]]]>>>> /x");
|
||||
let _ = parse_lossy(&[0xFF; 64]);
|
||||
for cut in 0..SAMPLE.len() {
|
||||
let _ = parse_lossy(&SAMPLE[..cut]);
|
||||
}
|
||||
// Nesting past the limit ends there; the outer levels stay.
|
||||
let deep = [b"<< /K ".as_slice(), &[b'['; 200]].concat();
|
||||
assert!(matches!(parse_lossy(&deep).get("K"), Some(Value::Array(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_input_never_panics() {
|
||||
for cut in 0..SAMPLE.len() {
|
||||
let _ = parse(&SAMPLE[..cut]);
|
||||
}
|
||||
assert!(parse(b"<< /A").is_err());
|
||||
assert!(parse(&[b'['; 200]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
//! * [`Head`] reads only the header, colour mode data and image resources (size, ICC profile,
|
||||
//! 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.
|
||||
//!
|
||||
//! Implemented clean-room from Adobe's public "Photoshop File Formats
|
||||
//! Specification". Undocumented details that follow MIT-licensed psd-tools
|
||||
@@ -55,6 +56,7 @@ mod codepage;
|
||||
pub mod compression;
|
||||
pub mod csh;
|
||||
pub mod descriptor;
|
||||
pub mod engine_data;
|
||||
pub mod error;
|
||||
pub mod file;
|
||||
pub mod filter_effects;
|
||||
|
||||
Reference in New Issue
Block a user