Merge pull request #7046 from dotCooCoo/fix/issue-3349

.ai import: graphic styles of one paint open with their fill and stroke
This commit is contained in:
BFlat
2026-11-13 16:04:36 +07:00
committed by GitHub
5 changed files with 248 additions and 16 deletions
+26 -6
View File
@@ -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
+57
View File
@@ -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::<bool>(),
) {
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::<Vec<_>>().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.
+6 -1
View File
@@ -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<Appearance> {
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.
+114 -9
View File
@@ -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<StyleItem>,
/// 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<Result<Vec<StyleItem>, ()>>,
}
impl ArtStyle {
@@ -412,12 +421,22 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
let mut in_basic = false;
// A style that is one paint being read, and its pattern so far.
let mut simple: Option<Option<StyleItem>> = None;
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>, simple_pattern: Option<StyleItem>| {
// The same style's paint operators so far.
let mut one_paint: Option<OnePaint> = None;
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>, simple_pattern: Option<StyleItem>, one_paint: Option<OnePaint>| {
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<String, A
}
Tok::Name(n) => 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<String, A
f.repeated |= f.kinds.insert(key, kind).is_some();
}
}
style_paint_op(&mut filters, &mut simple, op, &operands, names);
style_paint_op(&mut filters, &mut simple, &mut one_paint, op, &operands, names);
if op == "," || op == ";" || op.starts_with('X') || op.starts_with('B') || op.len() <= 2 {
operands.clear();
}
@@ -571,7 +590,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
operands.clear();
}
}
finish(name, &mut filters, simple.take().flatten());
finish(name, &mut filters, simple.take().flatten(), one_paint.take());
styles
}
@@ -729,13 +748,99 @@ fn pattern_operands(vals: &[V], names: &mut Names) -> 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<Option<StyleItem>>, 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<Option<StyleItem>>,
one_paint: &mut Option<OnePaint>,
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<bool>,
}
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<Vec<StyleItem>, ()> {
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
+45
View File
@@ -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}");
}
}