effectcraft/crates/psd/src/engine_data.rs

289 lines
10 KiB
Rust

//! The text engine data of type layers (`EngineData` in the `TySh` descriptor): a PostScript-like
//! tree of dictionaries (`<< /Key value >>`), arrays (`[ ]`), numbers, names, booleans and
//! strings (`(þÿ` + UTF-16BE with backslash escapes `)`). Read best-effort.
#[derive(Clone, Debug, PartialEq)]
pub enum EValue {
Dict(Vec<(String, EValue)>),
Array(Vec<EValue>),
Num(f64),
Bool(bool),
Name(String),
Str(String),
}
impl EValue {
pub fn get(&self, key: &str) -> Option<&EValue> {
match self {
EValue::Dict(v) => v.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
pub fn idx(&self, i: usize) -> Option<&EValue> {
match self {
EValue::Array(v) => v.get(i),
_ => None,
}
}
/// Follow a `/`-separated path; numeric segments index arrays.
pub fn path(&self, p: &str) -> Option<&EValue> {
let mut cur = self;
for seg in p.split('/').filter(|s| !s.is_empty()) {
cur = match seg.parse::<usize>() {
Ok(i) => cur.idx(i)?,
Err(_) => cur.get(seg)?,
};
}
Some(cur)
}
pub fn num(&self) -> Option<f64> {
match self {
EValue::Num(n) => Some(*n),
_ => None,
}
}
pub fn str(&self) -> Option<&str> {
match self {
EValue::Str(s) | EValue::Name(s) => Some(s),
_ => None,
}
}
}
struct P<'a> {
b: &'a [u8],
i: usize,
}
impl P<'_> {
fn ws(&mut self) {
while self.i < self.b.len() && (self.b[self.i].is_ascii_whitespace() || self.b[self.i] == 0) {
self.i += 1;
}
}
fn peek(&self, s: &[u8]) -> bool {
self.b[self.i..].starts_with(s)
}
fn value(&mut self, depth: usize) -> Option<EValue> {
if depth > 128 {
return None;
}
self.ws();
if self.i >= self.b.len() {
return None;
}
if self.peek(b"<<") {
self.i += 2;
let mut v = vec![];
loop {
self.ws();
if self.i >= self.b.len() {
return Some(EValue::Dict(v));
}
if self.peek(b">>") {
self.i += 2;
return Some(EValue::Dict(v));
}
if self.b[self.i] != b'/' {
// Unexpected token: skip it.
self.i += 1;
continue;
}
let key = self.name();
let val = self.value(depth + 1)?;
v.push((key, val));
}
}
match self.b[self.i] {
b'[' => {
self.i += 1;
let mut v = vec![];
loop {
self.ws();
if self.i >= self.b.len() {
return Some(EValue::Array(v));
}
if self.b[self.i] == b']' {
self.i += 1;
return Some(EValue::Array(v));
}
let start = self.i;
match self.value(depth + 1) {
Some(x) => v.push(x),
None => return Some(EValue::Array(v)),
}
if self.i == start {
self.i += 1;
}
}
}
b'/' => Some(EValue::Name(self.name())),
b'(' => Some(EValue::Str(self.string())),
_ => {
let start = self.i;
while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !b"[]<>/(".contains(&self.b[self.i]) {
self.i += 1;
}
let tok = std::str::from_utf8(&self.b[start..self.i]).unwrap_or("");
if self.i == start {
self.i += 1;
return Some(EValue::Name(String::new()));
}
Some(match tok {
"true" => EValue::Bool(true),
"false" => EValue::Bool(false),
t => t.parse::<f64>().map(EValue::Num).unwrap_or_else(|_| EValue::Name(t.to_string())),
})
}
}
}
fn name(&mut self) -> String {
self.i += 1; // '/'
let start = self.i;
while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !b"[]<>/(".contains(&self.b[self.i]) {
self.i += 1;
}
String::from_utf8_lossy(&self.b[start..self.i]).into_owned()
}
fn string(&mut self) -> String {
self.i += 1; // '('
let mut raw = vec![];
while self.i < self.b.len() {
let c = self.b[self.i];
self.i += 1;
match c {
b'\\' if self.i < self.b.len() => {
let e = self.b[self.i];
self.i += 1;
raw.push(match e {
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
other => other,
});
}
b')' => break,
_ => raw.push(c),
}
}
if raw.starts_with(&[0xFE, 0xFF]) {
let u: Vec<u16> = raw[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
String::from_utf16_lossy(&u).trim_end_matches('\0').to_string()
} else {
String::from_utf8_lossy(&raw).into_owned()
}
}
}
/// Parse engine data (returns an empty dictionary on garbage).
pub fn parse(b: &[u8]) -> EValue {
let mut p = P { b, i: 0 };
p.value(0).unwrap_or(EValue::Dict(vec![]))
}
/// Encode a string the way engine data stores it (UTF-16BE with a BOM, escaped).
pub fn encode_string(s: &str) -> Vec<u8> {
let mut out = vec![b'(', 0xFE, 0xFF];
for u in s.encode_utf16() {
for b in u.to_be_bytes() {
if matches!(b, b'(' | b')' | b'\\') {
out.push(b'\\');
}
out.push(b);
}
}
out.push(b')');
out
}
/// The first style run's character attributes.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TextStyle {
pub font: Option<String>,
pub size: Option<f64>,
/// RGB 0..1.
pub color: Option<[f64; 3]>,
/// 0 left, 1 right, 2 centre, 3+ justified variants.
pub justification: Option<u8>,
pub tracking: Option<f64>,
pub leading: Option<f64>,
pub faux_bold: bool,
pub faux_italic: bool,
/// Paragraph (box) text rather than point text.
pub box_text: bool,
}
pub fn text_style(ed: &EValue) -> 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/0/StyleSheet/StyleSheetData");
let normal = resources.and_then(|r| r.path("StyleSheetSet/0/StyleSheetData"));
let attr = |k: &str| run.and_then(|r| r.get(k)).or_else(|| normal.and_then(|n| n.get(k)));
let font =
attr("Font").and_then(EValue::num).and_then(|i| resources?.path(&format!("FontSet/{}/Name", i as usize))).and_then(EValue::str).map(str::to_string);
let color = attr("FillColor").and_then(|c| c.get("Values")).and_then(|v| match v {
EValue::Array(a) if a.len() >= 4 => Some([a[1].num()?, a[2].num()?, a[3].num()?]),
_ => None,
});
let para = engine.path("ParagraphRun/RunArray/0/ParagraphSheet/Properties");
let para_normal = resources.and_then(|r| r.path("ParagraphSheetSet/0/Properties"));
let justification =
para.and_then(|p| p.get("Justification")).or_else(|| para_normal.and_then(|p| p.get("Justification"))).and_then(EValue::num).map(|n| n as u8);
let auto_leading = attr("AutoLeading").is_none_or(|v| *v != EValue::Bool(false));
let box_text = engine.path("Rendered/Shapes/Children/0/ShapeType").and_then(EValue::num) == Some(1.0);
TextStyle {
font,
size: attr("FontSize").and_then(EValue::num),
color,
justification,
tracking: attr("Tracking").and_then(EValue::num),
leading: if auto_leading { None } else { attr("Leading").and_then(EValue::num) },
faux_bold: attr("FauxBold") == Some(&EValue::Bool(true)),
faux_italic: attr("FauxItalic") == Some(&EValue::Bool(true)),
box_text,
}
}
/// Minimal engine data for a type layer with one style run (fixtures, and what a writer needs).
pub fn minimal(text: &str, font: &str, size: f64, color: [f64; 3], justification: u8) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(b"\n\n<<\n\t/EngineDict\n\t<<\n\t\t/Editor << /Text ");
out.extend_from_slice(&encode_string(&format!("{text}\r")));
out.extend_from_slice(b" >>\n\t\t/ParagraphRun << /RunArray [ << /ParagraphSheet << /Properties << /Justification ");
out.extend_from_slice(justification.to_string().as_bytes());
out.extend_from_slice(b" >> >> >> ] >>\n\t\t/StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /Font 0 /FontSize ");
out.extend_from_slice(format!("{size:.1}").as_bytes());
out.extend_from_slice(
format!(" /FillColor << /Type 1 /Values [ 1.0 {:.5} {:.5} {:.5} ] >> /AutoLeading true >> >> >> ] >>\n\t>>\n", color[0], color[1], color[2]).as_bytes(),
);
out.extend_from_slice(b"\t/ResourceDict << /FontSet [ << /Name ");
out.extend_from_slice(&encode_string(font));
out.extend_from_slice(b" /Type 0 >> ] >>\n>>");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal() {
let b = minimal("Hi (there)", "Arial-BoldMT", 36.0, [1.0, 0.5, 0.0], 2);
let v = parse(&b);
assert_eq!(v.path("EngineDict/Editor/Text").and_then(EValue::str), Some("Hi (there)\r"));
let st = text_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!(!st.box_text);
}
#[test]
fn garbage_is_harmless() {
let _ = parse(b"<< /A [ 1 2 << /B (unterminated");
let _ = parse(b"]]]>>>> /x");
let _ = parse(&[0xFF; 64]);
}
}