Merge pull request #7528 from dotCooCoo/feat/ai-style-named-gradients

PDF/.ai import: graphic styles that paint with a named gradient load with it
This commit is contained in:
BFlat
2026-11-14 21:54:55 +07:00
committed by GitHub
9 changed files with 185 additions and 25 deletions
+60 -2
View File
@@ -424,8 +424,8 @@ 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.
// Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a fill with a gradient
// the file doesn't define, which 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\
@@ -465,6 +465,64 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() {
assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3), ("Red outline", 4)], "{r}");
}
/// A `.ai` graphic style library's styles that paint with one of the file's gradients by name (a
/// fill style's, and the fill or stroke of a style of one paint) load with that gradient at the
/// style's angle, fitted to the bounds of each object the style is applied to; one naming a gradient
/// the file doesn't define is left out.
#[test]
fn graphic_styles_in_a_ai_library_that_paint_with_a_named_gradient_load_with_it() {
let gradient =
"%AI5_BeginGradient: (Sky)\n(Sky) 0 2 Bd\n[\n%_0 0 0 0 0 1 0 2 1 6 50 0 Bs\n%_0 0 0 0 1 1 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let band = "/KnownStyle :\n(Sky band) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain 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\n\
0 Bb\n2 (Sky) 0 0 45 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n";
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{band}{}{}{}%AI9_EndArtStyles\n\
%AI9_BeginArtStyleList\n(Sky band)\n(Sky fill)\n(Sky outline)\n(Dusk fill)\n%AI9_EndArtStyleList\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"),
one_paint("Sky outline", "0 R\n0 J 0 j 2 w 10 M []0 d\n1 Bb\n2 (Sky) 0 0 90 100 1 0 0 1 0 0 1 Bg\n0 BB\n1 0 Xd\n"),
one_paint("Dusk fill", "0 O\n0 Bb\n2 (Dusk) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n")
);
// A palette with the gradient's swatch, so the document opened from the file shows its gradient.
let palette = INKS_PALETTE.replace("0 0 Pb\n", "0 0 Pb\nBb\n2 (Sky) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Sky)\nPc\n");
let data = ai::editing_data(200.0, 100.0, &format!("{gradient}{palette}{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": "skies.ai"});
let r = s.execute("graphicStyle.loadLibrary", &p).unwrap();
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, ["Sky band", "Sky fill", "Sky outline"], "{lib}");
// The file's gradient, as the document opened from it has its swatch.
open(&mut s, "skies.ai", &file, json!({})).unwrap();
let sky = match s.doc().unwrap().doc.swatch("Sky").map(|w| w.paint.clone()) {
Some(vectorcraft_color::Paint::Gradient(g)) => g.gradient,
other => panic!("{other:?}"),
};
// Applied to a small and a large rectangle, each gradient is fitted to the rectangle it paints.
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
for (style, (x, size)) in [("Sky band", (10, 20)), ("Sky fill", (40, 200)), ("Sky outline", (10, 120))] {
let rect = json!({"x": x, "y": x, "width": size, "height": size / 2});
let id = NodeId(s.execute("shape.rectangle", &rect).unwrap()["id"].as_u64().unwrap());
let r = s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": [style], "apply": true, "ids": [id.0]})).unwrap();
assert_eq!(r["added"], json!([style]), "{r}");
let look = &s.doc().unwrap().doc.node(id).unwrap().appearance;
let (paint, angle, stroked) = match style {
"Sky outline" => (look.stroke().map(|st| &st.paint), 90.0, true),
"Sky band" => (look.fill().map(|f| &f.paint), 45.0, false),
_ => (look.fill().map(|f| &f.paint), 0.0, false),
};
let Some(vectorcraft_color::Paint::Gradient(g)) = paint else { panic!("{style}: {look:?}") };
assert_eq!((&g.gradient, g.geom, g.angle), (&sky, None, angle), "{style}");
assert_eq!(look.stroke().is_some(), stroked, "{style}: {look:?}");
if stroked {
assert!(look.fill().is_none() && look.stroke().is_some_and(|st| st.width == 2.0), "{look:?}");
}
}
}
/// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other
/// Library… in the Symbols panel lists them in the list's order.
#[test]
+1 -1
View File
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other Library…",
["Window", "Graphic Style Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}",
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library; a .ai or EPS style painting with one of the file's gradients by name takes it at the style's angle, fitted to each object it is applied to, VectorCraft's own placement) → {library: id, name, count}",
always,
load
),
+6 -3
View File
@@ -4329,7 +4329,8 @@ proptest! {
"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",
"0 Bb", "1 Bb", "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg", "2 (Sky) 0 0 1e400 100 1 0 0 1 0 0 Bg", "2 (Dusk) 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)),
@@ -4338,9 +4339,11 @@ proptest! {
) {
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.
// C, a red 2 pt stroke, comes first, so that it reads whatever A holds. The file defines the
// gradient Sky, which a gradient paint of A may name.
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\
"%AI5_BeginGradient: (Sky)\n(Sky) 0 2 Bd\n[\n%_0 0 0 0 0 1 0 2 1 6 50 0 Bs\n%_0 0 0 0 1 1 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n\
%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")
+1 -1
View File
@@ -1147,7 +1147,7 @@ impl<'a> Reader<'a> {
continue;
}
let Some(st) = self.styles.get(&name) else { continue };
let Some(mut appearance) = appearance_from_art_style(st) else { continue };
let Some(mut appearance) = appearance_from_art_style(st, &self.gradients) else { continue };
// A Large Canvas copy's styles are at its art's scale ([`Self::ensure_space`]).
if let Some(s) = self.large_canvas.filter(|s| *s != 1.0) {
appearance.scale_strokes(s);
+23 -4
View File
@@ -1323,9 +1323,11 @@ 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).
/// this reads, paints with a pattern, or paints with a gradient that names no swatch of `gradients`.
/// 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> {
/// A style keeps only its gradients' names and angles, so each one's gradient, by VectorCraft's own
/// rule, is fitted to the bounds of whatever object the style is applied to, at its angle.
pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle, gradients: &HashMap<String, Gradient>) -> Option<Appearance> {
use crate::import::ai::style::StyleItem;
if !st.plain {
return None;
@@ -1341,8 +1343,15 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle)
// A graphic style's brush isn't checked against the file's brushes: it is left out.
StyleItem::Stroke { brush: Some(_), .. } => return None,
StyleItem::Stroke { .. } => stroke_layer(item)?,
StyleItem::Gradient { .. } | StyleItem::Pattern { .. } => return None,
StyleItem::Gradient { name, angle } => AppearanceItem::Fill(FillLayer::new(named_gradient(name.as_deref()?, *angle, gradients)?)),
StyleItem::Pattern { .. } => return None,
};
// A one-paint style's gradient stroke names its swatch (`style::OnePaint::items`).
if let AppearanceItem::Stroke(s) = &mut layer
&& let Paint::Gradient(g) = &s.paint
{
s.paint = named_gradient(g.swatch.as_deref()?, g.angle, gradients)?;
}
if let Some(look) = st.looks.get(n) {
with_look(&mut layer, look);
}
@@ -1351,6 +1360,14 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle)
Some(Appearance { items, effects: st.effects.clone(), contents_index: st.contents_index })
}
/// The gradient of `gradients` named `name` at `angle` degrees, fitted to the bounds of the object it
/// paints (`None` when there is no such gradient).
fn named_gradient(name: &str, angle: f64, gradients: &HashMap<String, Gradient>) -> Option<Paint> {
let mut g = vectorcraft_color::gradient::GradientPaint::new(gradients.get(name)?.clone());
g.angle = angle;
Some(Paint::Gradient(Box::new(g)))
}
/// What the reader makes of art style `name` in `section` (the text between `%AI9_BeginArtStyles`
/// and its end), for writers that check a style before they write it.
#[derive(Clone, Debug)]
@@ -1400,7 +1417,9 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
_ => None,
})
.collect();
let mut appearance = appearance_from_art_style(&st)?;
// No gradient is taken by name: on an object, one that isn't its own is placed where its drawn
// look has it.
let mut appearance = appearance_from_art_style(&st, &HashMap::new())?;
for (item, brush) in appearance.items.iter_mut().zip(brushes) {
if let (AppearanceItem::Stroke(s), Some(brush)) = (item, brush) {
s.brush = Some(brush);
+54 -9
View File
@@ -216,7 +216,8 @@ pub(super) struct ArtStyle {
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
/// stroke whose paint isn't read here (a gradient that names no swatch or isn't in a gradient
/// instance, 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>, ()>>,
}
@@ -313,6 +314,8 @@ struct Filter {
gradient_of: Option<(Option<String>, f64)>,
/// `(name) … [matrix] p`, and the numbers between the name and the matrix.
pattern: Option<(String, Option<[f64; 6]>, Vec<f64>)>,
/// Its last fill paint, which replaces those before it.
fill_last: LastPaint,
/// The plug-in object it refers to (`/GObjRef : (name) (plug-in) /PluginObject`): its name and
/// plug-in name.
plugin_object: Option<(String, String)>,
@@ -346,6 +349,7 @@ impl Default for Filter {
gradient: false,
gradient_of: None,
pattern: None,
fill_last: LastPaint::None,
plugin_object: None,
xy: None,
xy_group: None,
@@ -354,6 +358,15 @@ impl Default for Filter {
}
}
/// Which paint a filter's paint operators set last.
#[derive(Clone, Copy, Debug, PartialEq)]
enum LastPaint {
None,
Color,
Gradient,
Pattern,
}
/// Most filters read in one style.
const MAX_FILTERS: usize = 4096;
@@ -662,7 +675,7 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V], names: &mut Names) {
| "xz" => {
let Some((p, _)) = paint::color_op_in(op, vals, names) else { return };
if op.ends_with(|c: char| c.is_ascii_lowercase()) {
f.fill = Some(p);
(f.fill, f.fill_last) = (Some(p), LastPaint::Color);
} else {
f.stroke = Some(p);
}
@@ -719,7 +732,7 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V], names: &mut Names) {
}
// `flag (name) x y angle length a b c d tx ty Bg`.
"Bg" => {
f.gradient = true;
(f.gradient, f.fill_last) = (true, LastPaint::Gradient);
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)));
let name = at.and_then(|i| vals.get(i)).and_then(|v| names.text(v));
let n: Vec<f64> = at.map_or(&[][..], |i| vals.get(i + 1..).unwrap_or_default()).iter().filter_map(V::num).collect();
@@ -728,7 +741,7 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V], names: &mut Names) {
}
"p" => {
if let Some(pattern) = pattern_operands(vals, names) {
f.pattern = Some(pattern);
(f.pattern, f.fill_last) = (Some(pattern), LastPaint::Pattern);
}
}
_ => {}
@@ -786,6 +799,11 @@ struct OnePaint {
stroke_unread: bool,
/// Inside a gradient instance (`flag Bb` … `BB`): whether it paints the stroke (flag 1).
instance: Option<bool>,
/// The gradient swatch's name and angle of its last fill paint, when that is a gradient
/// instance's `Bg`.
fill_gradient: Option<(String, f64)>,
/// The same for its last stroke paint.
stroke_gradient: Option<(String, f64)>,
}
impl OnePaint {
@@ -811,9 +829,26 @@ impl OnePaint {
_ => {}
}
paint_op(&mut self.paint, op, vals, names);
// A gradient instance's `Bg` names the swatch its fill or stroke paints with; any other
// paint of that side replaces it. Outside an instance, which side it paints isn't known.
let named = || match (op, &self.paint.gradient_of) {
("Bg", Some((Some(name), angle))) => Some((name.clone(), *angle)),
_ => None,
};
match op {
"Bg" if self.instance == Some(true) => self.stroke_gradient = named(),
"Bg" if self.instance == Some(false) => self.fill_gradient = named(),
"Bg" => (self.fill_gradient, self.stroke_gradient) = (None, None),
"p" | "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "xa" | "xx" | "xk" | "xs" | "xz" => self.fill_gradient = None,
"P" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" => self.stroke_gradient = None,
_ => {}
}
}
/// Its fill and stroke ([`ArtStyle::one_paint`]).
/// Its fill and stroke ([`ArtStyle::one_paint`]). A gradient fill is its [`StyleItem::Gradient`];
/// a gradient stroke's paint is a gradient that only names its swatch (`swatch`) at its angle,
/// with default stops: `read::ops`' `appearance_from_art_style` gives it the file's gradient of
/// that name.
fn items(&self) -> Result<Vec<StyleItem>, ()> {
let f = &self.paint;
if !self.xd {
@@ -822,14 +857,22 @@ impl OnePaint {
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())),
match (&f.fill, &self.fill_gradient) {
(Some(p), _) if !self.fill_unread => items.push(StyleItem::Fill(p.clone())),
(_, Some((name, angle))) if self.fill_unread => items.push(StyleItem::Gradient { name: Some(name.clone()), angle: *angle }),
_ => return Err(()),
}
}
if stroked {
match &f.stroke {
Some(p) if !self.stroke_unread => items.push(StyleItem::Stroke {
let named = self.stroke_gradient.as_ref().filter(|_| self.stroke_unread).map(|(name, angle)| {
Paint::Gradient(Box::new(vectorcraft_color::gradient::GradientPaint {
angle: *angle,
swatch: Some(name.clone()),
..vectorcraft_color::gradient::GradientPaint::new(vectorcraft_color::gradient::Gradient::default())
}))
});
match named.as_ref().or(f.stroke.as_ref().filter(|_| !self.stroke_unread)) {
Some(p) => items.push(StyleItem::Stroke {
paint: p.clone(),
width: f.width,
cap: f.cap,
@@ -1396,6 +1439,8 @@ fn paint_item(f: &Filter) -> Result<Option<StyleItem>, ()> {
match f.name.as_str() {
FILL if !f.paints.1 => Ok(None),
FILL => match (&f.pattern, &f.gradient_of, &f.fill) {
(.., Some(p)) if f.fill_last == LastPaint::Color => Ok(Some(StyleItem::Fill(p.clone()))),
(_, Some((name, angle)), _) if f.fill_last == LastPaint::Gradient => Ok(Some(StyleItem::Gradient { name: name.clone(), angle: *angle })),
(Some((name, matrix, controls)), ..) => Ok(Some(StyleItem::Pattern { name: name.clone(), matrix: *matrix, controls: controls.clone() })),
(None, Some((name, angle)), _) => Ok(Some(StyleItem::Gradient { name: name.clone(), angle: *angle })),
(None, None, _) if f.gradient => Ok(Some(StyleItem::Gradient { name: None, angle: 0.0 })),
+34 -4
View File
@@ -471,6 +471,28 @@ fn lettering() -> String {
format!("/KnownStyle :\n(Lettering) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n")
}
/// A fill filter's last paint is its fill: a color, a gradient or a pattern replaces whichever of the
/// others came before it.
#[test]
fn a_fill_filter_paints_with_its_last_paint() {
let gradient = "0 Bb\n2 (Sky) 0 0 30 1 1 0 0 1 0 0 1 Bg\n0 BB";
let pattern = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p";
let color = "0 0 0 0 1 0 0 Xa";
let items = |paint: String| {
let root = compound("Chain Style Filter", &[fill(&paint)]);
let data = format!("/KnownStyle :\n(S) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n");
art_styles(data.as_bytes()).remove("S").map(|s| s.items).unwrap_or_default()
};
for before in [gradient, pattern] {
assert!(matches!(items(format!("{before}\n{color}")).as_slice(), [StyleItem::Fill(Paint::Solid { .. })]), "{before}");
}
for before in [color, pattern] {
assert_eq!(items(format!("{before}\n{gradient}")), [StyleItem::Gradient { name: Some("Sky".into()), angle: 30.0 }], "{before}");
}
assert!(matches!(items(format!("{color}\n{pattern}")).as_slice(), [StyleItem::Pattern { .. }]));
assert!(matches!(items(format!("{gradient}\n{pattern}")).as_slice(), [StyleItem::Pattern { .. }]));
}
#[test]
fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() {
// GUARDS: more than one fill, or an effect on a fill, made the style not plain, and type
@@ -1544,14 +1566,22 @@ fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() {
// 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.
// A gradient instance's fill is its swatch at its angle; its stroke, after a solid one too, is a
// gradient naming its swatch at its angle, at the stroke's weight.
let gradient_fill = one("0 Bb\n2 (Sky) 0 0 30 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n");
assert_eq!(gradient_fill, Some(Ok(vec![StyleItem::Gradient { name: Some("Sky".into()), angle: 30.0 }])));
let gradient_stroke = one("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");
let Some(Ok(items)) = &gradient_stroke else { panic!("{gradient_stroke:?}") };
let [StyleItem::Stroke { paint: Paint::Gradient(g), width, .. }] = items.as_slice() else { panic!("{items:?}") };
assert_eq!((g.swatch.as_deref(), g.angle, g.geom, *width), (Some("Ramp"), 90.0, None, 2.0));
// Not read: no `Xd`, a pattern fill, a pattern stroke, a gradient outside a gradient instance, 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(),
"2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 1 Xd\n".to_string(),
"0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(),
"0 0 1 0 k\n1 1 Xd\n".to_string(),
] {
assert_eq!(one(&paint), Some(Err(())), "{paint}");
+5 -1
View File
@@ -4376,7 +4376,11 @@ with); without `path` it returns `{data}`, and `user: true` saves into the user
(category `user`, User Defined). `graphicStyle.loadLibrary {path? | data? | dataBase64?, name?}` loads a `.vcstyles`
file, or another document's graphic styles, as a library to add from (a `.ai` or EPS whose editing data holds graphic
styles and no layers gives the styles of its style list, in that order); a file in the user library folder, or a file
with the same extension and bytes as one there, loads as that User Defined library.
with the same extension and bytes as one there, loads as that User Defined library. A `.ai` or EPS style that paints a
fill or stroke with one of the file's gradients by name loads with that gradient at the style's angle. The style stores
only the gradient's name and angle, so the gradient is fitted to the bounds of each object the style is applied to; this
placement is VectorCraft's own rule. A style that names a gradient the file doesn't define, or paints with a pattern, is
left out.
## Symbol libraries
+1
View File
@@ -612,6 +612,7 @@ since 2026-10-01.
- **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged.
- **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules.
- **Tab stops in `.ai`/EPS type and right-aligned lines with tabs (#6147):** type from the text document keeps its paragraphs' tab stops (key 30: positions and left, center, right and decimal alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line, with its hyphen when the line is hyphenated) against the right edge; centred and justified lines, point type and right-to-left paragraphs are aligned as a whole as before. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`. Scores unchanged.
- **`.ai`/EPS graphic styles that paint with a named gradient:** a graphic style library's style (or a file's listed style) whose fill, or the fill or stroke of a style of one paint, paints with one of the file's gradients by name loads with that gradient at the style's angle, fitted to the bounds of each object it is applied to (VectorCraft's own placement; the style stores only the gradient's name and angle). A style naming a gradient the file doesn't define, or painting with a pattern, is still left out. Evidence: `fileio::tests_aiimport::graphic_styles_in_a_ai_library_that_paint_with_a_named_gradient_load_with_it`, `style::tests::a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on`. Scores unchanged.
- **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged.
- **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged.
- **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged.