diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 0f866c6588..fafcc12215 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -424,25 +424,45 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() { /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n" ) }; + // Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a gradient fill, + // which isn't read, so it is left out rather than listed with no paint. + let one_paint = |name: &str, paint: &str| format!("/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n"); let styles = format!( - "%AI9_BeginArtStyles\n{}{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n%AI9_EndArtStyleList\n", + "%AI9_BeginArtStyles\n{}{}{}{}{}%AI9_EndArtStyles\n\ + %AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n(Red outline)\n(Sky fill)\n%AI9_EndArtStyleList\n", style("Blue", "0 0 0 0 0 0 1 Xa\n"), style("Green", "0 0 0 0 0 1 0 Xa\n"), - style("Red", "0 0 0 0 1 0 0 Xa\n") + style("Red", "0 0 0 0 1 0 0 Xa\n"), + one_paint("Red outline", "0 R\n0 1 1 0 K\n0 J 0 j 2 w 10 M []0 d\n1 0 Xd\n"), + one_paint("Sky fill", "0 O\n0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n") ); let data = ai::editing_data(200.0, 100.0, &styles); let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"); let mut s = Session::new(); let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fills.ai"}); let r = s.execute("graphicStyle.loadLibrary", &p).unwrap(); - assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(3)), "{r}"); - let lib = s.execute("graphicStyle.library", &json!({"library": r["library"]})).unwrap(); + assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(4)), "{r}"); + let library = r["library"].clone(); + let lib = s.execute("graphicStyle.library", &json!({"library": library})).unwrap(); let names: Vec<&str> = lib["styles"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect(); - assert_eq!(names, ["Red", "Green", "Blue"], "{lib}"); + assert_eq!(names, ["Red", "Green", "Blue", "Red outline"], "{lib}"); + // The outline style strokes a rectangle red, 2 pt wide, and leaves it unfilled. + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["Red outline"], "apply": true, "ids": [id.0]})).unwrap(); + assert_eq!(r["added"], json!(["Red outline"]), "{r}"); + let doc = &s.doc().unwrap().doc; + let look = &doc.node(id).unwrap().appearance; + assert!(look.fill().is_none(), "{look:?}"); + let strokes: Vec<_> = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).collect(); + assert_eq!(strokes.len(), 1, "{look:?}"); + let stroke = look.stroke().unwrap(); + assert_eq!((stroke.paint.color(), stroke.width), (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0), "{look:?}"); + assert_eq!(doc.graphic_style("Red outline").map(|g| &g.appearance), Some(&**look)); let r = open(&mut s, "fills.ai", &file, json!({})).unwrap(); let doc = &s.doc().unwrap().doc; let styles: Vec<(&str, u32)> = doc.graphic_styles.iter().map(|g| (g.name.as_str(), g.id)).collect(); - assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}"); + assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3), ("Red outline", 4)], "{r}"); } /// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 52be19a952..37f8c63acf 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4318,6 +4318,63 @@ proptest! { survive("style library in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; } + /// Graphic style libraries whose styles are one paint (`/SimpleStyle`): any paint operators in + /// any order, with any operands or none, `Xd` missing or repeated, and the paint left open. The + /// library is also loaded in the Graphic Style Libraries panel and its styles applied to artwork. + #[test] + fn ai_style_libraries_of_one_paint_styles_never_panic( + // Each operator, and whether it is a paint operator rather than any text. + ops in prop::collection::vec(prop_oneof![ + prop::sample::select(vec![ + "0 R", "0 O", "0 1 1 0 K", "1 0 0 0 k", "0 0 0 0 1 0 0 Xa", "0 0 0 0 1 0 0 XA", "0.5 G", "0 g", "K", "Xa", + "0 J 0 j 2 w 10 M []0 d", "-1 w", "1e400 w", "0 M", "[3 -1]0 d", "[]0 d", + "1 0 Xd", "0 1 Xd", "1 1 Xd", "0 0 Xd", "1 Xd", "Xd", "0 1 0 0 0 Xy", "1 0.5 1 1 0 Xy", "Xy", + "0 Bb", "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg", "0 BB", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P", "0 XR", + ]).prop_map(|op| (op.to_string(), true)), + "[ -~]{0,12}".prop_map(|text| (text, false)), + ], 0..12), + closed in any::(), + ) { + use vectorcraft_testkit::ai; + let close = if closed { "/Paint ;\n/Def ;\n" } else { "" }; + // C, a red 2 pt stroke, comes first, so that it reads whatever A holds. + let header = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(C) /Name ,\n/SimpleStyle :\n0 R\n0 1 1 0 K\n2 w\n1 0 Xd\n/Paint ;\n/Def ;\n\ + /KnownStyle :\n(A) /Name ,\n/SimpleStyle :\n{}\n{close}/KnownStyle :\n(B) /Name ,\n/SimpleStyle :\n1 0 Xd\n/Paint ;\n/Def ;\n\ + %AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(A)\n(B)\n(C)\n%AI9_EndArtStyleList\n", + ops.iter().map(|(op, _)| op.as_str()).collect::>().join("\n") + ); + let library = ai::editing_data(200.0, 100.0, &header); + survive("one-paint style library", || Some(vectorcraft_eps::layered_ai(library.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let file = ai::ai(library.as_bytes(), ai::page_pdf()); + // How many objects C's look reached, what applying it answered, and whether that look is + // C's red 2 pt stroke with no fill. + let (mut styled, mut applied, mut red_stroke) = (0, Value::Null, false); + survive("one-paint styles applied", || { + let mut s = rich_session(); + let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fuzz.ai"}); + let library = no_panic(s.execute("graphicStyle.loadLibrary", &p)).ok()?["library"].clone(); + no_panic(s.execute("select.all", &json!({}))).ok()?; + // Every style is added and the first one, A when it reads, applied; then C is applied. + let _ = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "apply": true}))); + applied = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["C"], "apply": true}))).ok()?; + let d = (*s.doc().ok()?.doc).clone(); + if let Some(look) = applied["applied"].as_str().and_then(|name| d.graphic_style(name)).map(|g| &g.appearance) { + let strokes = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).count(); + red_stroke = look.fill().is_none() + && strokes == 1 + && look.stroke().is_some_and(|s| (s.paint.color(), s.width) == (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0)); + d.walk(|n| styled += usize::from(n.appearance == *look)); + } + Some(d) + })?; + // Text that isn't a paint operator can leave a string or a comment open over the style list. + if ops.iter().all(|(_, paint)| *paint) { + prop_assert!(applied["applied"].is_string() && red_stroke && styled > 0, "the stroke style wasn't applied: {applied}"); + } + } + /// Objects on Path records of any shape: ids in any order, repeated or missing, any number of /// objects, pivots and angles out of range or not finite, spacings, order tags that disagree, /// groups nested or cut short, and versioned content left open. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index bc58858b91..9f6a1dd6af 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1231,13 +1231,18 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p /// A graphic style's appearance from art style `st`, on no object: `None` when the style isn't one /// this reads, or paints with a gradient or a pattern (placed on each object as its drawn look has them). +/// A style that is one paint is its fill and stroke ([`crate::import::ai::style::ArtStyle::one_paint`]). pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) -> Option { use crate::import::ai::style::StyleItem; if !st.plain { return None; } + let style_items = match &st.one_paint { + Some(one) => one.as_ref().ok()?, + None => &st.items, + }; let mut items = vec![]; - for (n, item) in st.items.iter().enumerate() { + for (n, item) in style_items.iter().enumerate() { let mut layer = match item { StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), // A graphic style's brush isn't checked against the file's brushes: it is left out. diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 6a75837733..482c172386 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -32,6 +32,10 @@ //! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it //! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency. //! +//! A style with no filters is one paint (`/SimpleStyle :` … `/Paint ;` in place of `/ActiveStyle`): +//! the fill's and stroke's colors and the stroke's style as an object's own paint operators write +//! them, `stroke fill Xd` turning each on, and an `Xy` for its transparency. +//! //! A stroke with a Calligraphic or Art brush is its own basic filter, `(Adobe Calligraphic Brush //! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for //! the object as entries (see [`super::brush`]). @@ -206,6 +210,11 @@ pub(super) struct ArtStyle { /// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such /// styles for their fills and strokes (`ate`). `None` for any other paint or style. pub simple_pattern: Option, + /// A style that is one paint: its fill and stroke, bottom first, each there when its + /// `stroke fill Xd` turns it on (none when neither is on). `Err` when it turns on a fill or + /// stroke whose paint isn't read here (a gradient, a pattern, a color that isn't read) or has + /// no `Xd`. `None` for a style with filters, whose paint is [`Self::items`]. + pub one_paint: Option, ()>>, } impl ArtStyle { @@ -412,12 +421,22 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap> = None; - let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option| { + // The same style's paint operators so far. + let mut one_paint: Option = None; + let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option, one_paint: Option| { if let Some(name) = name { // Only a style that is one paint holds a pattern for type: one with filters as well // paints with more than that. let simple_pattern = simple_pattern.filter(|_| filters.is_empty()); - styles.insert(name, ArtStyle { simple_pattern, ..style(filters) }); + let mut st = ArtStyle { simple_pattern, ..style(filters) }; + if let Some(one) = one_paint.filter(|_| filters.is_empty()) { + if let Some((mode, opacity)) = one.paint.xy { + let (isolate, knockout) = one.paint.xy_group.unwrap_or((false, Knockout::Off)); + st.transparency = Some((opacity, super::read::blend_mode(mode), isolate, knockout)); + } + st.one_paint = Some(one.items()); + } + styles.insert(name, st); } filters.clear(); }; @@ -454,12 +473,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap match n { b"KnownStyle" => { - finish(name.take(), &mut filters, simple.take().flatten()); + finish(name.take(), &mut filters, simple.take().flatten(), one_paint.take()); operands.clear(); open.clear(); (pending, in_basic, style_entry, other_entry) = (None, false, None, None); } - b"SimpleStyle" if filters.is_empty() => simple = Some(None), + b"SimpleStyle" if filters.is_empty() => (simple, one_paint) = (Some(None), Some(OnePaint::default())), b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), @@ -559,7 +578,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap HashMap Option<(String, Option<[f6 } /// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style -/// that is one paint (`simple`, while one is read). -fn style_paint_op(filters: &mut [Filter], simple: &mut Option>, op: &str, vals: &[V], names: &mut Names) { +/// that is one paint (`simple` and `one_paint`, while one is read). +fn style_paint_op( + filters: &mut [Filter], + simple: &mut Option>, + one_paint: &mut Option, + op: &str, + vals: &[V], + names: &mut Names, +) { if let Some(f) = filters.last_mut() { paint_op(f, op, vals, names); - } else if let Some(paint) = simple.as_mut() { + return; + } + if let Some(paint) = simple.as_mut() { simple_paint_op(paint, op, vals, names); } + if let Some(one) = one_paint.as_mut() { + one.op(op, vals, names); + } +} + +/// The paint operators of a style that is one paint (`/SimpleStyle :` … `/Paint ;`), read as a +/// fill's or stroke's style reads them. +#[derive(Default)] +struct OnePaint { + paint: Filter, + /// Its `stroke fill Xd` was read. + xd: bool, + /// Its last fill paint is a pattern (`p`) or may be a gradient (`Bg`). + fill_unread: bool, + /// Its last stroke paint is a pattern (`P`) or may be a gradient (`Bg`). + stroke_unread: bool, + /// Inside a gradient instance (`flag Bb` … `BB`): whether it paints the stroke (flag 1). + instance: Option, +} + +impl OnePaint { + fn op(&mut self, op: &str, vals: &[V], names: &mut Names) { + match op { + "Xd" => self.xd |= vals.iter().filter_map(V::num).count() >= 2, + "p" => self.fill_unread = true, + "P" => self.stroke_unread = true, + // The flag of the instance it is in says which one it paints, as an object's does + // (`Instance::begin` in `paint.rs`); outside one it is read as either one's. + "Bb" => self.instance = Some(vals.iter().filter_map(V::num).next_back() == Some(1.0)), + "BB" => self.instance = None, + "Bg" => match self.instance { + Some(true) => self.stroke_unread = true, + Some(false) => self.fill_unread = true, + None => (self.fill_unread, self.stroke_unread) = (true, true), + }, + // A color replaces what came before it; one that isn't read leaves none. + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "xa" | "xx" | "xk" | "xs" | "xz" => { + (self.fill_unread, self.paint.fill) = (false, None); + } + "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" => (self.stroke_unread, self.paint.stroke) = (false, None), + _ => {} + } + paint_op(&mut self.paint, op, vals, names); + } + + /// Its fill and stroke ([`ArtStyle::one_paint`]). + fn items(&self) -> Result, ()> { + let f = &self.paint; + if !self.xd { + return Err(()); + } + let (stroked, filled) = f.paints; + let mut items = vec![]; + if filled { + match &f.fill { + Some(p) if !self.fill_unread => items.push(StyleItem::Fill(p.clone())), + _ => return Err(()), + } + } + if stroked { + match &f.stroke { + Some(p) if !self.stroke_unread => items.push(StyleItem::Stroke { + paint: p.clone(), + width: f.width, + cap: f.cap, + join: f.join, + miter: f.miter, + dash: f.dash.clone(), + profile: None, + align: StrokeAlign::Center, + brush: None, + }), + _ => return Err(()), + } + } + Ok(items) + } } /// A paint operator of a style that is one paint: a pattern (`p`) is kept as its diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index c6debd42f9..ea8428809c 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1512,3 +1512,48 @@ fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() { assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}"); } } + +/// A style that is one paint reads as the fill and stroke its `Xd` turns on, in the paint's colors +/// and the stroke's style; one that turns on a paint not read here, or has no `Xd`, isn't read. +#[test] +fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() { + let one = |paint: &str| { + let data = format!("/KnownStyle :\n(S) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n"); + art_styles_in(data.as_bytes(), &mut Names::default()).remove("S").and_then(|s| s.one_paint) + }; + let stroke = "0 R\n0 1 1 0 K\n1 J 2 j 3 w 4 M [2 1]0 d\n"; + let Some(Ok(items)) = one(&format!("0 O\n0 0 1 0 k\n{stroke}1 1 Xd\n")) else { panic!("{:?}", one(stroke)) }; + let [StyleItem::Fill(fill), StyleItem::Stroke { paint, width, cap, join, miter, dash, profile: None, align: StrokeAlign::Center, brush: None }] = + items.as_slice() + else { + panic!("{items:?}") + }; + assert_eq!((fill.color(), paint.color()), (Some(Color::cmyk(0.0, 0.0, 1.0, 0.0)), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)))); + assert_eq!((*width, *cap, *join, *miter), (3.0, LineCap::Round, LineJoin::Bevel, 4.0)); + assert_eq!(dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0])); + // Only what `Xd` turns on; nothing when it turns on neither. + assert!(matches!(one(&format!("0 0 1 0 k\n{stroke}1 0 Xd\n")), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }]))); + assert_eq!(one(&format!("0 0 1 0 k\n{stroke}0 0 Xd\n")), Some(Ok(vec![]))); + // The last paint of each one counts: a solid color after a pattern or a gradient replaces it. + let solid_fill = |before: &str| one(&format!("{before}\n0 0 1 0 k\n0 1 Xd\n")); + for before in ["(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"] { + assert!(matches!(solid_fill(before), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)])), "{before}"); + } + let solid_stroke = one("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n0 1 1 0 K\n1 0 Xd\n"); + assert!(matches!(solid_stroke, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }]))); + // A gradient instance's flag says which one it paints: a stroke gradient leaves a solid fill read. + let fill_and_stroke_gradient = one("0 0 1 0 k\n1 Bb\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 1 Xd\n"); + assert!(matches!(fill_and_stroke_gradient, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)]))); + // Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a gradient stroke after a solid one, a paint + // turned on with no color. + for paint in [ + format!("0 0 1 0 k\n{stroke}"), + "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n".to_string(), + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(), + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n1 0 Xd\n".to_string(), + "0 R\n0 1 1 0 K\n1 Bb\n0 0 0 0 Bh\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n2 w\n1 0 Xd\n".to_string(), + "0 0 1 0 k\n1 1 Xd\n".to_string(), + ] { + assert_eq!(one(&paint), Some(Err(())), "{paint}"); + } +}