mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 14:14:11 +00:00
Merge pull request #5227 from dotCooCoo/fix/ai-bas-relief-settings
.ai import: Bas Relief opens with its settings
This commit is contained in:
@@ -4720,6 +4720,13 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"(Fill Style Filter) 0 0 /Filter ,",
|
||||
"(Adobe Outer Glow) 1 0 /Filter ,",
|
||||
"(Adobe Drop Shadow) 1 0 /Filter ,",
|
||||
"(PSAdapter_plugin_BsRl) 1 0 /Filter ,",
|
||||
"/Binary : /ASCII85Decode ,",
|
||||
"/Binary : /ASCII85Decode ,\n%!!!!\"z~>\n; (data) ,",
|
||||
"\n%zzzz~>\n",
|
||||
"; (data) ,",
|
||||
"8 /Int (dataSize) ,",
|
||||
"8 /Real (dataSize) ,",
|
||||
"(Adobe Pathfinder) 1 0 /Filter ,",
|
||||
"0 /Int (Command) ,",
|
||||
"0.5 /Real (Mix) ,",
|
||||
@@ -5229,6 +5236,53 @@ proptest! {
|
||||
survive(what, || vectorcraft_engine::cmd::fileio::load("x.eps", &file).ok().map(|l| l.doc))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// A filter-gallery effect's settings block in an art style: fields of any key, marker, type and
|
||||
/// value, objects nested in objects, any version and `dataSize`, and its ASCII85 lines damaged or cut.
|
||||
#[test]
|
||||
fn ai_filter_gallery_settings_never_panic(
|
||||
fields in prop::collection::vec(arb_settings_field(), 0..8),
|
||||
inner in prop::collection::vec(arb_settings_field(), 0..4),
|
||||
nesting in 0usize..4,
|
||||
version in prop_oneof![Just(1u32), any::<u32>()],
|
||||
size in prop_oneof![Just(None), any::<i64>().prop_map(Some)],
|
||||
cut in 0usize..600,
|
||||
edits in prop::collection::vec((0usize..600, prop::sample::select(vec!['%', '~', '>', 'z', 'v', '!', 'u', '\n', ';', ' '])), 0..6),
|
||||
) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let object = |body: Vec<u8>| [&b"Objc"[..], &0x8000_0000u32.to_be_bytes(), b"Clss", &(body.len() as u32 + 4).to_be_bytes(), &1u32.to_be_bytes(), &body].concat();
|
||||
let mut nested = inner.concat();
|
||||
for _ in 0..nesting {
|
||||
nested = object(nested);
|
||||
}
|
||||
let data = [version.to_be_bytes().to_vec(), fields.concat(), nested].concat();
|
||||
let lines: String = ascii85(&data).as_bytes().chunks(60).map(|c| format!("%{}\n", String::from_utf8_lossy(c))).collect();
|
||||
let size = size.unwrap_or(data.len() as i64);
|
||||
let block = mutate_text(&format!("/Binary : /ASCII85Decode ,\n{lines}; (data) ,\n{size} /Int (dataSize) ,\n"), cut, &edits);
|
||||
let styles = format!(
|
||||
"%AI9_BeginArtStyles\n/KnownStyle :\n(R) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(PSAdapter_plugin_BsRl) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{block}; /Dict ;\n /Part ,\n;\n%AI9_EndArtStyles\n"
|
||||
);
|
||||
let hidden = ai::rect(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
let art = format!("0 Ae\nu\n0 1 1 0 1 0 0 Xa\n{}f\nU\n%_{hidden}f\n1 (R) XW\n", ai::rect(0.0, 0.0, 10.0, 10.0));
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art)));
|
||||
survive("filter-gallery settings", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
let eps = ai::eps(data.as_bytes(), ai::page_ps());
|
||||
survive("filter-gallery settings in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// A field of a filter-gallery settings descriptor: a key, a marker, a type and a value, each one
|
||||
/// that is read or anything else.
|
||||
fn arb_settings_field() -> impl Strategy<Value = Vec<u8>> {
|
||||
let key = prop_oneof![prop::sample::select(vec![*b"GEfk", *b"Dtl ", *b"Smth", *b"LghD", *b"Xtra"]), any::<[u8; 4]>()];
|
||||
let marker = prop_oneof![Just(0u32), Just(0x2000), Just(0x8000_0000), any::<u32>()];
|
||||
let kind = prop_oneof![prop::sample::select(vec![*b"long", *b"doub", *b"bool", *b"LghD", *b"GEft"]), any::<[u8; 4]>()];
|
||||
let value = prop_oneof![
|
||||
prop::sample::select(vec![b"LDBt".to_vec(), b"BsRl".to_vec(), 8i32.to_be_bytes().to_vec(), vec![1], f64::NAN.to_be_bytes().to_vec()]),
|
||||
prop::collection::vec(any::<u8>(), 0..12),
|
||||
];
|
||||
(key, marker, kind, value).prop_map(|(k, m, t, v)| [&k[..], &m.to_be_bytes(), &t, &v].concat())
|
||||
}
|
||||
|
||||
// ---------- font files a folder search reads ----------
|
||||
|
||||
@@ -160,8 +160,8 @@ pub(super) struct ArtStyle {
|
||||
pub plain: bool,
|
||||
/// It is one fill and one stroke at most, in solid colours or the object's own gradient, with
|
||||
/// no transparency or effects of their own, and only Drop Shadow, Inner Glow, Outer Glow,
|
||||
/// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke)
|
||||
/// and some Distort & Transform effects ([`any_object_takes`]) on the object
|
||||
/// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke),
|
||||
/// Bas Relief read from its settings block and some Distort & Transform effects ([`any_object_takes`]) on the object
|
||||
/// (and Pathfinder effects when it has no fills or strokes): what any object is read with. Type takes the other plain styles too (its drawn look
|
||||
/// can't be retyped); other objects keep their drawn look, which is the app's own rendering.
|
||||
pub simple: bool,
|
||||
@@ -277,6 +277,8 @@ struct Filter {
|
||||
xy: Option<(usize, f32)>,
|
||||
/// `… isolate knockout … Xy`.
|
||||
xy_group: Option<(bool, Knockout)>,
|
||||
/// Its settings block's bytes (`/Binary : /ASCII85Decode ,`), `Err` when it isn't read.
|
||||
data: Option<Result<Vec<u8>, ()>>,
|
||||
}
|
||||
|
||||
impl Default for Filter {
|
||||
@@ -304,6 +306,7 @@ impl Default for Filter {
|
||||
plugin_object: None,
|
||||
xy: None,
|
||||
xy_group: None,
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,12 +386,18 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
let mut style_entry: Option<Kind> = None;
|
||||
// A name of a basic filter's that may be an entry's type, with the string that follows it.
|
||||
let mut other_entry: Option<Option<String>> = None;
|
||||
// The last name was `/Binary`.
|
||||
let mut binary = false;
|
||||
let mut lex = Lexer::new(data);
|
||||
while let Some(t) = lex.next_token() {
|
||||
if let (Some(a), Tok::Num(v)) = (array.as_mut(), &t.tok) {
|
||||
a.push(V::Num(*v));
|
||||
continue;
|
||||
}
|
||||
let after_binary = binary;
|
||||
if let Tok::Name(n) = &t.tok {
|
||||
binary = *n == b"Binary";
|
||||
}
|
||||
match t.tok {
|
||||
Tok::Num(v) => operands.push(V::Num(v)),
|
||||
Tok::Str(s) | Tok::Hex(s) => {
|
||||
@@ -397,6 +406,11 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
}
|
||||
operands.push(V::Str(s));
|
||||
}
|
||||
// A basic filter's settings block: its bytes are on the comment lines the lexer skips.
|
||||
Tok::Name(b"ASCII85Decode") if after_binary && in_basic => {
|
||||
keep_block(&mut lex, data.len(), filters.last_mut());
|
||||
operands.clear();
|
||||
}
|
||||
Tok::Name(n) => match n {
|
||||
b"KnownStyle" => {
|
||||
finish(name.take(), &mut filters);
|
||||
@@ -520,6 +534,62 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
styles
|
||||
}
|
||||
|
||||
/// Reads the settings block that `lex` (over `data_len` bytes) has just reached into `filter`'s
|
||||
/// `data`; a second block on one filter, or a block that isn't read, leaves it `Err`.
|
||||
fn keep_block(lex: &mut Lexer, data_len: usize, filter: Option<&mut Filter>) {
|
||||
let block = binary_block(lex, data_len).ok_or(());
|
||||
if let Some(f) = filter {
|
||||
f.data = Some(if f.data.is_some() { Err(()) } else { block });
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of the settings block whose `/Binary : /ASCII85Decode` `lex` (over `data_len` bytes)
|
||||
/// has just read: the rest of that line is its `,`, each line after it holds ASCII85 after a `%`
|
||||
/// until `~>`, and `; (data) ,` follows. `lex` is moved past the `~>` when the lines up to it are
|
||||
/// the block's and fit in [`adapter::MAX_ENCODED`] bytes, and past `; (data)` when they follow.
|
||||
/// `None` when the block is malformed or decodes to more than [`adapter::MAX_DECODED`] bytes.
|
||||
fn binary_block(lex: &mut Lexer, data_len: usize) -> Option<Vec<u8>> {
|
||||
let from = lex.offset();
|
||||
let text = lex.slice(from, from.saturating_add(adapter::MAX_ENCODED).min(data_len));
|
||||
let mut lines = text.split(|b| matches!(b, b'\r' | b'\n'));
|
||||
let first = lines.next()?;
|
||||
if first.trim_ascii() != b"," {
|
||||
return None;
|
||||
}
|
||||
// Where the line being read starts in `text`.
|
||||
let mut at = first.len() + 1;
|
||||
let mut encoded = vec![];
|
||||
for line in lines {
|
||||
let start = at;
|
||||
at += line.len() + 1;
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// A line that isn't the block's ends it unread, the lexer left before it.
|
||||
let digits = line.strip_prefix(b"%")?;
|
||||
if let Some(i) = super::super::lex::find(digits, b"~>") {
|
||||
encoded.extend_from_slice(digits.get(..i + 2)?);
|
||||
lex.seek(from + start + 1 + i + 2);
|
||||
return block_data(lex, &encoded);
|
||||
}
|
||||
encoded.extend_from_slice(digits);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The bytes of a settings block's ASCII85 `encoded`, when `; (data)` follows it in `lex`. When it
|
||||
/// doesn't, `lex` is left before those tokens, so the next style's `/KnownStyle` is still read.
|
||||
fn block_data(lex: &mut Lexer, encoded: &[u8]) -> Option<Vec<u8>> {
|
||||
let at = lex.offset();
|
||||
let data = crate::ps::ascii85_decode_within(std::str::from_utf8(encoded).ok()?, adapter::MAX_DECODED)?;
|
||||
let (close, key) = (lex.next_token().map(|t| t.tok), lex.next_token().map(|t| t.tok));
|
||||
if close == Some(Tok::Word(b";")) && key == Some(Tok::Str(b"data".to_vec())) {
|
||||
return Some(data);
|
||||
}
|
||||
lex.seek(at);
|
||||
None
|
||||
}
|
||||
|
||||
/// A paint operator of a fill's or stroke's style.
|
||||
fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
|
||||
let last = vals.iter().rev().find_map(V::num);
|
||||
@@ -1068,8 +1138,14 @@ fn effect(f: &Filter, under: Under) -> Option<Effect> {
|
||||
TWIST => ("distort.twist", json!({"angle": real(f, "angle", 0.0, -3600.0..=3600.0)?})),
|
||||
ZIG_ZAG => ("distort.zigZag", zig_zag(f)?),
|
||||
GAUSSIAN => ("blur.gaussian", json!({"radius": param("blur").unwrap_or(5.0).clamp(0.0, 1000.0)})),
|
||||
// Its settings are in a Photoshop plug-in's binary block: the effect's defaults stand in.
|
||||
BAS_RELIEF => ("sketch.basRelief", json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})),
|
||||
BAS_RELIEF => (
|
||||
"sketch.basRelief",
|
||||
match &f.data {
|
||||
// Without a settings block, the effect's defaults stand in.
|
||||
None => json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}),
|
||||
Some(data) => adapter::bas_relief(&descriptor(f, data)?)?,
|
||||
},
|
||||
),
|
||||
SHAPE_EFFECTS => return shape_effect(f),
|
||||
PATHFINDER => return pathfinder_effect(f),
|
||||
_ => return None,
|
||||
@@ -1077,6 +1153,18 @@ fn effect(f: &Filter, under: Under) -> Option<Effect> {
|
||||
Some(Effect { id: id.into(), params, visible: f.visible })
|
||||
}
|
||||
|
||||
/// The fields of filter `f`'s settings block `data`, when it was read and its `dataSize`, written
|
||||
/// as `/Int`, is its length.
|
||||
fn descriptor(f: &Filter, data: &Result<Vec<u8>, ()>) -> Option<Vec<(adapter::Key, adapter::Value)>> {
|
||||
let data = data.as_ref().ok()?;
|
||||
(f.kinds.get("dataSize") == Some(&Kind::Int)).then_some(())?;
|
||||
let size = f.params.get("dataSize").copied()?;
|
||||
if size != data.len() as f64 {
|
||||
return None;
|
||||
}
|
||||
adapter::decode(data)
|
||||
}
|
||||
|
||||
/// The Effect › Pathfinder effect of [`PATHFINDER_COMMANDS`] that an integer `Command` names, when
|
||||
/// every key of [`PATHFINDER_OPTIONS`] is there with its kind and value (Soft Mix's `Mix` from 0.01
|
||||
/// to 1, as its Mixing Rate in percent) and the only other key is the `DisplayString` text, each
|
||||
@@ -1389,6 +1477,21 @@ fn any_object_takes(e: &Effect) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does every Bas Relief filter in `filters` have a settings block, so that it reads from its
|
||||
/// settings and not from its defaults standing in?
|
||||
fn bas_relief_read(filters: &[Filter]) -> bool {
|
||||
filters.iter().all(|f| f.name != BAS_RELIEF || f.data.is_some())
|
||||
}
|
||||
|
||||
/// Is `e` read on the whole object: Bas Relief when `bas_relief` says its settings were read, and
|
||||
/// otherwise what [`any_object_takes`] says?
|
||||
fn object_takes(e: &Effect, bas_relief: bool) -> bool {
|
||||
if e.id == "sketch.basRelief" {
|
||||
return bas_relief;
|
||||
}
|
||||
any_object_takes(e)
|
||||
}
|
||||
|
||||
/// Do Free Distort params `p`'s corners (top left, top right, bottom right, bottom left) make a
|
||||
/// parallelogram, so that the bilinear map is affine?
|
||||
fn affine(p: &serde_json::Value) -> bool {
|
||||
@@ -1443,13 +1546,14 @@ fn style(filters: &[Filter]) -> ArtStyle {
|
||||
b.plain = false;
|
||||
}
|
||||
st.plain = b.plain;
|
||||
let bas_relief = bas_relief_read(filters);
|
||||
let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count();
|
||||
st.simple = st.plain
|
||||
&& count(true) <= 1
|
||||
&& count(false) <= 1
|
||||
&& st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. }))
|
||||
&& st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty())
|
||||
&& st.effects.iter().all(any_object_takes)
|
||||
&& st.effects.iter().all(|e| object_takes(e, bas_relief))
|
||||
// An effect that moves the outline is drawn on its own: with others, their order and what
|
||||
// scales with it aren't kept.
|
||||
&& (!st.moves_outline() || st.effects.len() == 1)
|
||||
@@ -1458,5 +1562,6 @@ fn style(filters: &[Filter]) -> ArtStyle {
|
||||
st
|
||||
}
|
||||
|
||||
mod adapter;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! The settings of a filter-gallery effect in an art style: a basic filter's `/Binary :
|
||||
//! /ASCII85Decode ,` block, whose comment lines hold the bytes until `~>`, then `; (data) ,` and the
|
||||
//! byte count `n /Int (dataSize) ,`. The bytes are a descriptor, all numbers big-endian:
|
||||
//!
|
||||
//! ```text
|
||||
//! version (u32, 1)
|
||||
//! field* key (4 chars) marker (u32) …
|
||||
//! marker 0: type (4 chars: long, doub, bool) value (i32, f64 or one byte)
|
||||
//! marker 0x2000: an enumeration: type (4 chars) value (4 chars)
|
||||
//! marker 0x80000000: an object: class (4 chars) length (u32) and that many bytes, a descriptor
|
||||
//! ```
|
||||
//!
|
||||
//! A filter-gallery effect's first field is `GEfk`, an enumeration of type `GEft` whose value is the
|
||||
//! effect's four-character code. The keys and the enumerations' values are the ones the Photoshop
|
||||
//! Actions Event Guide lists for Bas Relief, and `GEfk` and `GEft` are the filter gallery's in
|
||||
//! published scripting examples. Anything else (another marker or type, a field past the end, bytes after the last field, more
|
||||
//! than [`MAX_FIELDS`] fields or objects nested more than [`MAX_DEPTH`] deep) isn't read.
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
/// Most bytes a block's text is read from (its comment lines and `~>`).
|
||||
pub(super) const MAX_ENCODED: usize = 6 * 1024;
|
||||
/// Most bytes a block decodes to.
|
||||
pub(super) const MAX_DECODED: usize = 4 * 1024;
|
||||
/// Most fields read in one descriptor, nested ones included.
|
||||
const MAX_FIELDS: usize = 32;
|
||||
/// Most levels of objects in objects.
|
||||
const MAX_DEPTH: usize = 2;
|
||||
|
||||
/// A field's value.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Value {
|
||||
Long(i32),
|
||||
Doub(f64),
|
||||
Bool(bool),
|
||||
/// An enumeration's type and value.
|
||||
Enum([u8; 4], [u8; 4]),
|
||||
/// An object's class and fields.
|
||||
Object([u8; 4], Vec<(Key, Value)>),
|
||||
}
|
||||
|
||||
/// A field's key.
|
||||
pub(super) type Key = [u8; 4];
|
||||
|
||||
/// The fields of descriptor `data`; `None` when it isn't one read here.
|
||||
pub(super) fn decode(data: &[u8]) -> Option<Vec<(Key, Value)>> {
|
||||
let mut count = 0;
|
||||
fields(data, 0, &mut count)
|
||||
}
|
||||
|
||||
fn fields(data: &[u8], depth: usize, count: &mut usize) -> Option<Vec<(Key, Value)>> {
|
||||
let mut r = Reader { data, at: 0 };
|
||||
if r.u32()? != 1 {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![];
|
||||
while r.at < data.len() {
|
||||
*count += 1;
|
||||
if *count > MAX_FIELDS {
|
||||
return None;
|
||||
}
|
||||
let key = r.four()?;
|
||||
let value = match r.u32()? {
|
||||
0 => match &r.four()? {
|
||||
b"long" => Value::Long(i32::from_be_bytes(r.four()?)),
|
||||
b"doub" => Value::Doub(f64::from_be_bytes(r.eight()?)).finite()?,
|
||||
b"bool" => match r.take(1)? {
|
||||
[0] => Value::Bool(false),
|
||||
[1] => Value::Bool(true),
|
||||
_ => return None,
|
||||
},
|
||||
_ => return None,
|
||||
},
|
||||
0x2000 => Value::Enum(r.four()?, r.four()?),
|
||||
0x8000_0000 if depth < MAX_DEPTH => {
|
||||
let class = r.four()?;
|
||||
let n = usize::try_from(r.u32()?).ok()?;
|
||||
Value::Object(class, fields(r.take(n)?, depth + 1, count)?)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
out.push((key, value));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
impl Value {
|
||||
fn finite(self) -> Option<Self> {
|
||||
match self {
|
||||
Value::Doub(v) if !v.is_finite() => None,
|
||||
v => Some(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
|
||||
let end = self.at.checked_add(n)?;
|
||||
let bytes = self.data.get(self.at..end)?;
|
||||
self.at = end;
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn four(&mut self) -> Option<[u8; 4]> {
|
||||
self.take(4)?.try_into().ok()
|
||||
}
|
||||
|
||||
fn eight(&mut self) -> Option<[u8; 8]> {
|
||||
self.take(8)?.try_into().ok()
|
||||
}
|
||||
|
||||
fn u32(&mut self) -> Option<u32> {
|
||||
Some(u32::from_be_bytes(self.four()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of a filter-gallery effect `code`, its `GEfk` field taken off; `None` when that field
|
||||
/// names another effect.
|
||||
fn gallery<'a>(fields: &'a [(Key, Value)], code: &[u8; 4]) -> Option<&'a [(Key, Value)]> {
|
||||
match fields.split_first() {
|
||||
Some((([b'G', b'E', b'f', b'k'], Value::Enum(kind, value)), rest)) => (kind == b"GEft" && value == code).then_some(rest),
|
||||
_ => Some(fields),
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole number in `range`.
|
||||
fn long(v: &Value, range: std::ops::RangeInclusive<i32>) -> Option<f64> {
|
||||
match v {
|
||||
Value::Long(n) if range.contains(n) => Some(f64::from(*n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch › Bas Relief's params (`sketch.basRelief`) from its fields: `Dtl ` (detail), `Smth`
|
||||
/// (smoothness) and `LghD` (the light's direction), each once; `None` when one is missing, out of
|
||||
/// range or not read here, or another field is there.
|
||||
pub(super) fn bas_relief(fields: &[(Key, Value)]) -> Option<Json> {
|
||||
let (mut detail, mut smoothness, mut light) = (None, None, None);
|
||||
for (key, value) in gallery(fields, b"BsRl")? {
|
||||
let slot = match key {
|
||||
b"Dtl " => &mut detail,
|
||||
b"Smth" => &mut smoothness,
|
||||
b"LghD" => &mut light,
|
||||
_ => return None,
|
||||
};
|
||||
if slot.is_some() {
|
||||
return None;
|
||||
}
|
||||
*slot = Some(match key {
|
||||
b"LghD" => json!(light_direction(value)?),
|
||||
_ => json!(long(value, 1..=15)?),
|
||||
});
|
||||
}
|
||||
Some(json!({"detail": detail?, "smoothness": smoothness?, "lightDirection": light?}))
|
||||
}
|
||||
|
||||
/// The light direction of a `LghD` enumeration's value (`typeLightDirection`).
|
||||
fn light_direction(v: &Value) -> Option<&'static str> {
|
||||
let Value::Enum(kind, value) = v else { return None };
|
||||
if kind != b"LghD" {
|
||||
return None;
|
||||
}
|
||||
Some(match value {
|
||||
b"LDBt" => "bottom",
|
||||
b"LDBL" => "bottomLeft",
|
||||
b"LDLf" => "left",
|
||||
b"LDTL" => "topLeft",
|
||||
b"LDTp" => "top",
|
||||
b"LDTR" => "topRight",
|
||||
b"LDRg" => "right",
|
||||
b"LDBR" => "bottomRight",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1051,6 +1051,184 @@ fn a_hidden_fill_comes_in_hidden() {
|
||||
assert_eq!(st.looks.iter().map(|l| l.visible).collect::<Vec<_>>(), [false, true]);
|
||||
}
|
||||
|
||||
/// A descriptor (version 1) of `fields`.
|
||||
fn descriptor(fields: &[Vec<u8>]) -> Vec<u8> {
|
||||
[vec![0, 0, 0, 1], fields.concat()].concat()
|
||||
}
|
||||
|
||||
fn long_field(key: &[u8; 4], v: i32) -> Vec<u8> {
|
||||
[&key[..], &[0; 4], b"long", &v.to_be_bytes()].concat()
|
||||
}
|
||||
|
||||
fn enum_field(key: &[u8; 4], kind: &[u8; 4], v: &[u8; 4]) -> Vec<u8> {
|
||||
[&key[..], &0x2000u32.to_be_bytes(), kind, v].concat()
|
||||
}
|
||||
|
||||
/// Bas Relief's fields: the filter-gallery code, `detail`, `smoothness` and the light direction `light`.
|
||||
fn relief_fields(detail: i32, smoothness: i32, light: &[u8; 4]) -> Vec<Vec<u8>> {
|
||||
vec![enum_field(b"GEfk", b"GEft", b"BsRl"), long_field(b"Dtl ", detail), long_field(b"Smth", smoothness), enum_field(b"LghD", b"LghD", light)]
|
||||
}
|
||||
|
||||
/// A settings block of `data`: the ASCII85 on comment lines, then `; (data) ,`.
|
||||
fn block(data: &[u8]) -> String {
|
||||
let lines: String = crate::ps::ascii85(data).lines().map(|l| format!("%{l}\n")).collect();
|
||||
format!("/Binary : /ASCII85Decode ,\n{lines}; (data) ,\n")
|
||||
}
|
||||
|
||||
/// The style `name`: a black fill and Bas Relief on the object, whose dictionary holds `body`.
|
||||
fn relief_text(name: &str, body: &str) -> String {
|
||||
let stack = compound("Stack Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Conduit Filter", "")]);
|
||||
let relief = basic("PSAdapter_plugin_BsRl", body);
|
||||
format!("/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack, relief]))
|
||||
}
|
||||
|
||||
/// The style of [`relief_text`].
|
||||
fn relief_style(body: &str) -> ArtStyle {
|
||||
art_styles(relief_text("Relief", body).as_bytes()).remove("Relief").unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settings_block_without_its_end_leaves_the_next_style_alone() {
|
||||
// GUARDS: the block's `~>` was looked for past its own lines, in the next style's block, which was skipped.
|
||||
let data = descriptor(&relief_fields(8, 1, b"LDBt"));
|
||||
let cut = format!("{}{} /Int (dataSize) ,\n", block(&data).replace("~>", ""), data.len());
|
||||
let whole = format!("{}{} /Int (dataSize) ,\n", block(&data), data.len());
|
||||
let styles = art_styles(format!("{}{}", relief_text("Cut", &cut), relief_text("Whole", &whole)).as_bytes());
|
||||
assert!(styles["Cut"].effects.is_empty(), "{:?}", styles["Cut"]);
|
||||
assert!(styles["Whole"].simple, "{:?}", styles["Whole"]);
|
||||
assert_eq!(styles["Whole"].effects[0].params["detail"], json!(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settings_block_without_its_data_key_leaves_the_next_style_alone() {
|
||||
// GUARDS: the tokens after the block's `;` were taken as its `(data)` key, the closing of its
|
||||
// dictionary or the next style's `/KnownStyle` among them, and the next style was lost.
|
||||
let data = descriptor(&relief_fields(8, 1, b"LDBt"));
|
||||
let whole = format!("{}{} /Int (dataSize) ,\n", block(&data), data.len());
|
||||
let keyless = block(&data).replace("(data) ,\n", "");
|
||||
let next = relief_text("Whole", &whole);
|
||||
// A style cut short right after its block's `;`, the next style's `/KnownStyle` following.
|
||||
let short = relief_text("Cut", &keyless);
|
||||
let short = short.get(..short.find("~>\n;").unwrap_or_default() + 4).unwrap_or_default();
|
||||
for text in [
|
||||
format!("{short}\n{next}"),
|
||||
format!("{}{next}", relief_text("Cut", &keyless)),
|
||||
format!("{}{next}", relief_text("Cut", &format!("{keyless}{} /Int (dataSize) ,\n", data.len()))),
|
||||
] {
|
||||
let styles = art_styles(text.as_bytes());
|
||||
assert!(styles.get("Whole").is_some_and(|s| s.simple), "{styles:?}");
|
||||
assert_eq!(styles["Whole"].effects[0].params["detail"], json!(8.0));
|
||||
assert!(!styles.get("Cut").is_some_and(|s| s.simple), "{styles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`relief_style`] with the settings block of `data` and a `dataSize` of its length.
|
||||
fn relief_with(data: &[u8]) -> ArtStyle {
|
||||
relief_style(&format!("{}300 /Int (PrevDres) ,\n{} /Int (dataSize) ,\n1 /Bool (go) ,\n", block(data), data.len()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bas_relief_reads_its_settings_block() {
|
||||
// GUARDS: the effect's defaults stood in for the settings, and an object other than type with it
|
||||
// came in as its drawn look.
|
||||
for (code, light) in [
|
||||
(b"LDBt", "bottom"),
|
||||
(b"LDBL", "bottomLeft"),
|
||||
(b"LDLf", "left"),
|
||||
(b"LDTL", "topLeft"),
|
||||
(b"LDTp", "top"),
|
||||
(b"LDTR", "topRight"),
|
||||
(b"LDRg", "right"),
|
||||
(b"LDBR", "bottomRight"),
|
||||
] {
|
||||
let st = relief_with(&descriptor(&relief_fields(8, 1, code)));
|
||||
assert!(st.plain && st.simple, "{st:?}");
|
||||
let [e] = st.effects.as_slice() else { panic!("{st:?}") };
|
||||
assert_eq!((e.id.as_str(), &e.params), ("sketch.basRelief", &json!({"detail": 8.0, "smoothness": 1.0, "lightDirection": light})));
|
||||
}
|
||||
// Without the filter-gallery code, and with the fields in another order.
|
||||
let [_, detail, smoothness, light] = <[Vec<u8>; 4]>::try_from(relief_fields(15, 15, b"LDTp")).unwrap();
|
||||
let st = relief_with(&descriptor(&[light, smoothness, detail]));
|
||||
assert!(st.simple, "{st:?}");
|
||||
assert_eq!(st.effects[0].params, json!({"detail": 15.0, "smoothness": 15.0, "lightDirection": "top"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bas_relief_without_a_settings_block_keeps_the_effects_defaults_on_type_only() {
|
||||
let st = relief_style("300 /Int (PrevDres) ,\n");
|
||||
assert!(st.plain && !st.simple, "{st:?}");
|
||||
assert_eq!(st.effects[0].params, json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bas_relief_with_settings_not_read_here_keeps_the_drawn_look() {
|
||||
let good = || relief_fields(8, 1, b"LDBt");
|
||||
let with = |i: usize, field: Vec<u8>| {
|
||||
let mut f = good();
|
||||
f[i] = field;
|
||||
descriptor(&f)
|
||||
};
|
||||
let cases: Vec<(&str, Vec<u8>)> = vec![
|
||||
("detail 0", with(1, long_field(b"Dtl ", 0))),
|
||||
("detail 16", with(1, long_field(b"Dtl ", 16))),
|
||||
("smoothness -1", with(2, long_field(b"Smth", -1))),
|
||||
("an unknown direction", with(3, enum_field(b"LghD", b"LghD", b"LDXx"))),
|
||||
("a direction of another type", with(3, enum_field(b"LghD", b"Dirc", b"LDBt"))),
|
||||
("a detail that isn't a whole number", with(1, [&b"Dtl "[..], &[0; 4], b"doub", &8.0f64.to_be_bytes()].concat())),
|
||||
("another effect's code", with(0, enum_field(b"GEfk", b"GEft", b"Crst"))),
|
||||
("an unknown key", [descriptor(&good()), long_field(b"Xtra", 1)].concat()),
|
||||
("a key twice", [descriptor(&good()), long_field(b"Dtl ", 8)].concat()),
|
||||
("a missing key", descriptor(&good()[..3])),
|
||||
("a byte after the last field", [descriptor(&good()), vec![0]].concat()),
|
||||
("a cut field", descriptor(&good()).split_last().unwrap().1.to_vec()),
|
||||
("version 2", [vec![0, 0, 0, 2], good().concat()].concat()),
|
||||
];
|
||||
for (what, data) in cases {
|
||||
let st = relief_with(&data);
|
||||
assert!(!st.plain && !st.simple && st.effects.is_empty(), "{what}: {st:?}");
|
||||
}
|
||||
let data = descriptor(&good());
|
||||
let encoded = block(&data);
|
||||
for (what, body) in [
|
||||
("a wrong dataSize", format!("{encoded}{} /Int (dataSize) ,\n", data.len() + 1)),
|
||||
("no dataSize", encoded.clone()),
|
||||
("a dataSize of another kind", format!("{encoded}{} /Real (dataSize) ,\n", data.len())),
|
||||
("no ~>", format!("{}{} /Int (dataSize) ,\n", encoded.replace("~>", ""), data.len())),
|
||||
("a digit past u", format!("{}{} /Int (dataSize) ,\n", encoded.replacen("%", "%v", 2), data.len())),
|
||||
("a line without %", format!("{}{} /Int (dataSize) ,\n", encoded.replacen("\n%", "\n", 1), data.len())),
|
||||
("no (data) key", format!("{}{} /Int (dataSize) ,\n", encoded.replace("(data)", "(other)"), data.len())),
|
||||
("two blocks", format!("{encoded}{encoded}{} /Int (dataSize) ,\n", data.len())),
|
||||
] {
|
||||
let st = relief_style(&body);
|
||||
assert!(!st.plain && st.effects.is_empty(), "{what}: {st:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_blocks_are_bounded() {
|
||||
// More than the decoded limit, though its text is short (`z` is four zero bytes).
|
||||
let zeros = format!("/Binary : /ASCII85Decode ,\n%{}~>\n; (data) ,\n4100 /Int (dataSize) ,\n", "z".repeat(1025));
|
||||
assert!(relief_style(&zeros).effects.is_empty());
|
||||
// An end further than the encoded limit.
|
||||
let far = format!("/Binary : /ASCII85Decode ,\n{}%~>\n; (data) ,\n0 /Int (dataSize) ,\n", "%!!!!!\n".repeat(1000));
|
||||
assert!(relief_style(&far).effects.is_empty());
|
||||
// Objects nest two deep at most, and a descriptor holds 32 fields at most.
|
||||
let object = |inner: Vec<u8>| [&b"Objc"[..], &0x8000_0000u32.to_be_bytes(), b"Clss", &(inner.len() as u32).to_be_bytes(), &inner].concat();
|
||||
let two = descriptor(&[object(descriptor(&[object(descriptor(&[long_field(b"Valu", 1)]))]))]);
|
||||
assert!(adapter::decode(&two).is_some());
|
||||
let three = descriptor(&[object(descriptor(&[object(descriptor(&[object(descriptor(&[]))]))]))]);
|
||||
assert!(adapter::decode(&three).is_none());
|
||||
assert!(adapter::decode(&descriptor(&vec![long_field(b"Valu", 1); 32])).is_some());
|
||||
assert!(adapter::decode(&descriptor(&vec![long_field(b"Valu", 1); 33])).is_none());
|
||||
let long_object = [&b"Objc"[..], &0x8000_0000u32.to_be_bytes(), b"Clss", &u32::MAX.to_be_bytes()].concat();
|
||||
assert!(adapter::decode(&descriptor(&[long_object])).is_none());
|
||||
let nan = [&b"Valu"[..], &[0; 4], b"doub", &f64::NAN.to_be_bytes()].concat();
|
||||
assert!(adapter::decode(&descriptor(&[nan])).is_none());
|
||||
let flag = |b: u8| [&b"Valu"[..], &[0; 4], b"bool", &[b]].concat();
|
||||
assert!(adapter::decode(&descriptor(&[flag(1)])).is_some());
|
||||
assert!(adapter::decode(&descriptor(&[flag(2)])).is_none());
|
||||
}
|
||||
|
||||
/// A Convert to Shape filter whose dictionary holds `keys`.
|
||||
fn shape(keys: &str) -> String {
|
||||
basic("Adobe Shape Effects", &format!("(Shape) /UnicodeString (DisplayString) ,\n{keys}"))
|
||||
|
||||
@@ -5900,6 +5900,51 @@ fn a_path_with_a_pathfinder_effect_keeps_its_drawn_look() {
|
||||
assert_eq!(effects, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_with_bas_relief_comes_in_with_its_settings() {
|
||||
// GUARDS: Bas Relief's settings block wasn't read, so the object came in as its drawn look.
|
||||
let settings: Vec<u8> = [
|
||||
&[0, 0, 0, 1][..],
|
||||
b"Dtl ",
|
||||
&[0; 4],
|
||||
b"long",
|
||||
&5i32.to_be_bytes(),
|
||||
b"Smth",
|
||||
&[0; 4],
|
||||
b"long",
|
||||
&9i32.to_be_bytes(),
|
||||
b"LghD",
|
||||
&0x2000u32.to_be_bytes(),
|
||||
b"LghD",
|
||||
b"LDTR",
|
||||
]
|
||||
.concat();
|
||||
let lines: String = crate::ps::ascii85(&settings).lines().map(|l| format!("%{l}\n")).collect();
|
||||
let style = format!(
|
||||
"%AI9_BeginArtStyles\n\
|
||||
/KnownStyle :\n(Carved) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\
|
||||
/BasicFilter :\n(PSAdapter_plugin_BsRl) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/Binary : /ASCII85Decode ,\n{lines}; (data) ,\n\
|
||||
{} /Int (dataSize) ,\n; /Dict ;\n /Part ,\n;\n\
|
||||
%AI9_EndArtStyles\n",
|
||||
settings.len()
|
||||
);
|
||||
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_0 0 0 1 k\n%_{hidden_rect}f\n1 (Carved) XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
let r = read_ok(&format!("{style}{}", layer(&art_)));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1);
|
||||
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
|
||||
let [relief] = a[0].appearance.effects.as_slice() else { panic!("{:#?}", a[0].appearance) };
|
||||
assert_eq!(
|
||||
(relief.id.as_str(), &relief.params),
|
||||
("sketch.basRelief", &serde_json::json!({"detail": 5.0, "smoothness": 9.0, "lightDirection": "topRight"}))
|
||||
);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_with_convert_to_shape_comes_in_as_itself_with_the_effect_live() {
|
||||
// GUARDS: `Adobe Shape Effects` wasn't read, so the object came in as its drawn look.
|
||||
|
||||
+17
-17
File diff suppressed because one or more lines are too long
@@ -1307,6 +1307,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).
|
||||
In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`).
|
||||
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points.
|
||||
PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked.
|
||||
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).
|
||||
|
||||
Reference in New Issue
Block a user