Merge pull request #5967 from dotCooCoo/fix/ai-brushed-paths

.ai import: paths with an Art brush open with the brush on their stroke
This commit is contained in:
BFlat
2026-11-07 09:09:01 +08:00
committed by GitHub
16 changed files with 887 additions and 69 deletions
+16 -5
View File
@@ -13,15 +13,26 @@ fn luminance(c: &Color) -> f32 {
}
fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color {
let lum = luminance(c);
// Black ink alone is its own tint: its amount is the tint, not how light it displays (100% K
// displays lighter than black).
let light = match *c {
Color::Cmyk { c, m, y, k } if c == 0.0 && m == 0.0 && y == 0.0 => 1.0 - k,
_ => luminance(c),
};
// A process or gray stroke color's tints and shades stay in its model.
let (white, black) = match key {
Color::Cmyk { .. } => (Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)),
Color::Gray { .. } => (Color::gray(0.0), Color::gray(1.0)),
_ => (Color::WHITE, Color::BLACK),
};
match mode {
Colorization::None => *c,
Colorization::Tints => key.lerp(&Color::WHITE, lum),
Colorization::Tints => key.lerp_keep_model(&white, light),
Colorization::TintsAndShades => {
if lum < 0.5 {
Color::BLACK.lerp(key, lum * 2.0)
if light < 0.5 {
key.lerp_keep_model(&black, 1.0 - light * 2.0)
} else {
key.lerp(&Color::WHITE, (lum - 0.5) * 2.0)
key.lerp_keep_model(&white, (light - 0.5) * 2.0)
}
}
Colorization::HueShift { key: art_key } => {
+49
View File
@@ -701,6 +701,55 @@ fn tints_colorization_uses_stroke_colour() {
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
}
/// Black ink alone is a tint by its amount: a process black stroke leaves it as it is, and another
/// process stroke color takes that share of its inks. Tints of a process color stay process.
#[test]
fn tints_of_black_ink_are_its_amount_of_the_stroke_color() {
let tinted = |art: Color, stroke: Color| {
let mut n = rect_art(1.0, 1.0);
n.appearance.set_fill(Paint::solid(art));
colorize(&mut n, Colorization::Tints, &Paint::solid(stroke));
n.appearance.fill_paint().color().unwrap()
};
let ink = Color::cmyk(0.0, 0.0, 0.0, 0.6);
let close = |a: Color, b: Color| {
let (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) = (a, b) else { panic!("{a:?} {b:?}") };
(c - c2).abs() < 1e-5 && (m - m2).abs() < 1e-5 && (y - y2).abs() < 1e-5 && (k - k2).abs() < 1e-5
};
assert!(close(tinted(ink, Color::cmyk(0.0, 0.0, 0.0, 1.0)), ink));
assert!(close(tinted(ink, Color::cmyk(0.0, 1.0, 1.0, 0.0)), Color::cmyk(0.0, 0.6, 0.6, 0.0)));
let [r, g, b] = tinted(ink, Color::rgb(1.0, 0.0, 0.0)).to_rgb();
assert!((r - 1.0).abs() < 1e-5 && (g - 0.4).abs() < 1e-5 && (b - 0.4).abs() < 1e-5, "{r} {g} {b}");
let Color::Gray { k } = tinted(ink, Color::gray(0.5)) else { panic!("tints of a gray stroke color stay gray") };
assert!((k - 0.3).abs() < 1e-5, "{k}");
}
/// Under Tints and Shades black ink stays black, however light 100% K displays, and a band of black
/// ink is its amount: K 0.5 is the stroke color. A process or gray stroke color stays in its model.
#[test]
fn tints_and_shades_of_black_ink_go_by_its_amount() {
let mapped = |k: f32, stroke: Color| {
let mut n = rect_art(1.0, 1.0);
n.appearance.set_fill(Paint::solid(Color::cmyk(0.0, 0.0, 0.0, k)));
colorize(&mut n, Colorization::TintsAndShades, &Paint::solid(stroke));
n.appearance.fill_paint().color().unwrap()
};
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-5);
let inks = |c: Color| {
let Color::Cmyk { c, m, y, k } = c else { panic!("a process color: {c:?}") };
[c, m, y, k]
};
let orange = Color::cmyk(0.0, 0.6, 1.0, 0.0);
assert!(close(inks(mapped(1.0, orange)), [0.0, 0.0, 0.0, 1.0]), "black stays black");
assert!(close(inks(mapped(0.5, orange)), [0.0, 0.6, 1.0, 0.0]), "the stroke color");
assert!(close(inks(mapped(0.25, orange)), [0.0, 0.3, 0.5, 0.0]), "a tint");
assert!(close(inks(mapped(0.75, orange)), [0.0, 0.3, 0.5, 0.5]), "a shade");
let Color::Gray { k } = mapped(0.75, Color::gray(0.4)) else { panic!("a gray stroke color stays gray") };
assert!((k - 0.7).abs() < 1e-5, "{k}");
let [r, g, b] = mapped(1.0, Color::rgb(1.0, 0.0, 0.0)).to_rgb();
assert!(r < 1e-5 && g < 1e-5 && b < 1e-5, "black stays black: {r} {g} {b}");
}
/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
fn type_art(fill: Paint, stroke: Paint) -> Node {
use vectorcraft_doc::{CharStyle, TextObject};
+1 -1
View File
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
has_doc,
place
),
+76 -8
View File
@@ -1,6 +1,6 @@
//! Resources placed art brings along: the images, symbols, pattern swatches and colour swatches it
//! uses (also through symbol and pattern art and through tint swatches) join the document. A name
//! the document already uses for something different gets a free variant (`Star 2`,
//! Resources placed art brings along: the images, symbols, pattern swatches, colour swatches and
//! brushes it uses (also through symbol, pattern and brush art and through tint swatches) join the
//! document. A name the document already uses for something different gets a free variant (`Star 2`,
//! `pdf-image-1 2`…) and the art's references follow; a colour linked to a swatch the document
//! defines differently keeps its colour, unlinked. Text styles are left as they are: the art carries
//! its own formatting.
@@ -8,6 +8,7 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use vectorcraft_brush::{Brush, BrushKind};
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind};
@@ -31,6 +32,7 @@ enum Ref<'a> {
Image(&'a mut String),
Symbol(&'a mut String),
Pattern(&'a mut String),
Brush(&'a mut String),
/// A colour's (or gradient's) swatch link; renaming it to `None` unlinks it.
Swatch(&'a mut Option<String>),
}
@@ -56,7 +58,12 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) {
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(fl) => paint_refs(&mut fl.paint, f),
AppearanceItem::Stroke(st) => paint_refs(&mut st.paint, f),
AppearanceItem::Stroke(st) => {
paint_refs(&mut st.paint, f);
if let Some(brush) = &mut st.brush {
f(Ref::Brush(brush));
}
}
}
}
match &mut n.kind {
@@ -80,6 +87,7 @@ struct Used {
images: Vec<String>,
symbols: Vec<String>,
patterns: Vec<String>,
brushes: Vec<String>,
swatches: Vec<String>,
}
@@ -89,6 +97,7 @@ impl Used {
Ref::Image(k) => (&mut self.images, &*k),
Ref::Symbol(k) => (&mut self.symbols, &*k),
Ref::Pattern(k) => (&mut self.patterns, &*k),
Ref::Brush(k) => (&mut self.brushes, &*k),
Ref::Swatch(Some(k)) => (&mut self.swatches, &*k),
Ref::Swatch(None) => return,
};
@@ -116,7 +125,9 @@ fn names<T: PartialEq>(
let entry = match existing(name) {
None => (Some(name.clone()), true),
Some(e) if e == d => (Some(name.clone()), false),
Some(_) => (Some(unique_name(name, |c| taken(c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true),
Some(_) => {
(Some(unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true)
}
};
out.insert(name.clone(), entry);
}
@@ -128,6 +139,18 @@ fn copies(m: &Renames) -> Vec<(&str, &str)> {
m.iter().filter(|(_, (_, copy))| *copy).filter_map(|(old, (new, _))| Some((old.as_str(), new.as_deref()?))).collect()
}
/// The art a brush holds.
fn brush_art(b: &mut Brush) -> Vec<&mut Node> {
match &mut b.kind {
BrushKind::Scatter(s) => vec![&mut s.art],
BrushKind::Art(a) => vec![&mut a.art],
BrushKind::Pattern(p) => {
[Some(&mut p.side), p.outer_corner.as_mut(), p.inner_corner.as_mut(), p.start.as_mut(), p.end.as_mut()].into_iter().flatten().collect()
}
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
}
}
/// Bring the resources `nodes` (art from `src`) use into `dst`, renaming on conflict, and point
/// `nodes` at the names they got.
pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
@@ -135,9 +158,10 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
for n in nodes.iter_mut() {
refs(n, &mut |r| used.note(r));
}
// Symbol and pattern art and tint swatches can use more resources (the lists grow while they
// are walked).
let (mut si, mut pi, mut wi) = (0, 0, 0);
let src_brushes = vectorcraft_brush::library(src);
// Symbol, pattern and brush art and tint swatches can use more resources (the lists grow while
// they are walked).
let (mut si, mut pi, mut wi, mut bi) = (0, 0, 0, 0);
loop {
let mut art: Vec<Node> = vec![];
if let Some(name) = used.symbols.get(si) {
@@ -151,6 +175,11 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
if let Some(mut paint) = src.swatch(name).map(|w| w.paint.clone()) {
paint_refs(&mut paint, &mut |r| used.note(r));
}
} else if let Some(name) = used.brushes.get(bi) {
bi += 1;
if let Some(mut b) = src_brushes.iter().find(|b| b.name == *name).cloned() {
art.extend(brush_art(&mut b).into_iter().map(|a| a.clone()));
}
} else {
break;
}
@@ -182,12 +211,38 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
Some((n.clone(), entry))
})
.collect();
// A brush whose art uses a resource that moves to another name isn't the document's brush of
// that name, whatever it looks like.
let moved = |map: &Renames, k: &str| map.get(k).is_some_and(|(new, _)| new.as_deref() != Some(k));
let moves_art = |b: &Brush| {
let (mut b, mut any) = (b.clone(), false);
for a in brush_art(&mut b) {
refs(a, &mut |r| {
any |= match r {
Ref::Image(k) => moved(&images, k),
Ref::Symbol(k) => moved(&symbols, k),
Ref::Pattern(k) => moved(&patterns, k),
Ref::Swatch(link) => link.as_deref().is_some_and(|k| moved(&swatches, k)),
Ref::Brush(_) => false,
}
});
}
any
};
let mut dst_brushes = vectorcraft_brush::library(dst);
let brushes = names(
&used.brushes,
|n| src_brushes.iter().find(|b| b.name == n).map(|b| (b, moves_art(b))),
|n| dst_brushes.iter().find(|b| b.name == n).map(|b| (b, false)),
|c| dst_brushes.iter().any(|b| b.name == c),
);
let mut rename = |r: Ref| {
let (map, k) = match r {
Ref::Image(k) => (&images, k),
Ref::Symbol(k) => (&symbols, k),
Ref::Pattern(k) => (&patterns, k),
Ref::Brush(k) => (&brushes, k),
Ref::Swatch(link) => {
if let Some((new, _)) = link.as_deref().and_then(|k| swatches.get(k)) {
*link = new.clone();
@@ -223,6 +278,19 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
dst.patterns.push(def);
}
}
let new_brushes = copies(&brushes);
if !new_brushes.is_empty() {
for (old, new) in new_brushes {
if let Some(mut def) = src_brushes.iter().find(|b| b.name == old).cloned() {
def.name = new.to_string();
for a in brush_art(&mut def) {
refs(a, &mut rename);
}
dst_brushes.push(def);
}
}
vectorcraft_brush::store(dst, &dst_brushes);
}
for (old, _) in copies(&swatches) {
if let Some(mut w) = src.swatch(old).cloned() {
paint_refs(&mut w.paint, &mut rename);
+92
View File
@@ -662,6 +662,98 @@ fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() {
assert!(linked, "still linked to the swatch it brought");
}
#[test]
fn placed_art_brings_its_brushes_renaming_on_conflict() {
// The file: a path with its own brush, and one with a brush the defaults hold.
let mut src = session();
src.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 20, "roundness": 30, "size": 8}})).unwrap();
src.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
src.execute("brush.apply", &json!({"name": "Nib"})).unwrap();
src.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
src.execute("brush.apply", &json!({"name": "Charcoal"})).unwrap();
let bytes = serialize(&mut src, "vectorcraft");
let brushes = |n: &Node| {
let mut names = vec![];
n.walk(&mut |c| names.extend(c.appearance.stroke().and_then(|st| st.brush.clone())));
names
};
// A document with a different "Nib".
let mut s = session();
s.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 90, "roundness": 100, "size": 2}})).unwrap();
let before = s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().len();
let r = place(&mut s, "art.vectorcraft", &bytes, json!({"crop": "bounding"}));
assert_eq!(brushes(&node(&s, &r["ids"][0])), ["Nib 2", "Charcoal"]);
assert_eq!(s.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(2.0), "its own stays");
assert_eq!(s.execute("brush.get", &json!({"name": "Nib 2"})).unwrap()["size"], json!(8.0));
assert_eq!(s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().len(), before + 1, "the same Charcoal is shared");
// A document without it gets it as it is.
let mut fresh = session();
let r = place(&mut fresh, "art.vectorcraft", &bytes, json!({"crop": "bounding"}));
assert_eq!(brushes(&node(&fresh, &r["ids"][0])), ["Nib", "Charcoal"]);
assert_eq!(fresh.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(8.0));
// The name a conflict would take is not one the art already uses.
src.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib 2", "params": {"angle": 40, "roundness": 50, "size": 5}})).unwrap();
src.execute("shape.rectangle", &json!({"x": 90, "y": 10, "width": 20, "height": 20})).unwrap();
src.execute("brush.apply", &json!({"name": "Nib 2"})).unwrap();
let bytes = serialize(&mut src, "vectorcraft");
let mut s = session();
s.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 90, "roundness": 100, "size": 2}})).unwrap();
let r = place(&mut s, "art.vectorcraft", &bytes, json!({"crop": "bounding"}));
assert_eq!(brushes(&node(&s, &r["ids"][0])), ["Nib 3", "Charcoal", "Nib 2"]);
assert_eq!(s.execute("brush.get", &json!({"name": "Nib 3"})).unwrap()["size"], json!(8.0));
assert_eq!(s.execute("brush.get", &json!({"name": "Nib 2"})).unwrap()["size"], json!(5.0));
}
/// A placed brush whose art uses an image the document holds under the same key, with other pixels,
/// isn't the document's brush of that name, though the two brushes read the same.
#[test]
fn a_placed_brush_whose_art_uses_a_renamed_image_is_copied() {
let file = |pixel: u8| {
let mut png = vec![];
image::GrayImage::from_pixel(2, 1, image::Luma([pixel])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.edit("Brush", |d, _| {
d.images.insert("k".into(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 2,
height: 1,
xf: Default::default(),
link: None,
placement: Default::default(),
pixels: None,
};
let art = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
let brush = vectorcraft_brush::Scatter { art, ..Default::default() };
vectorcraft_brush::store(d, &[vectorcraft_brush::Brush { name: "Speck".into(), kind: vectorcraft_brush::BrushKind::Scatter(brush) }]);
Ok(())
})
.unwrap();
s.execute("brush.apply", &json!({"name": "Speck"})).unwrap();
serialize(&mut s, "vectorcraft")
};
let mut s = session();
let first = place(&mut s, "one.vectorcraft", &file(0), json!({"crop": "bounding"}));
let second = place(&mut s, "two.vectorcraft", &file(255), json!({"crop": "bounding"}));
let brush_of = |r: &Value| {
let mut name = None;
node(&s, &r["ids"][0]).walk(&mut |c| name = name.take().or_else(|| c.appearance.stroke().and_then(|st| st.brush.clone())));
name
};
assert_eq!(brush_of(&first).as_deref(), Some("Speck"));
assert_eq!(brush_of(&second).as_deref(), Some("Speck 2"));
let doc = &s.doc().unwrap().doc;
let key = |name: &str| {
let Some(vectorcraft_brush::Brush { kind: vectorcraft_brush::BrushKind::Scatter(b), .. }) = vectorcraft_brush::find(doc, name) else {
panic!("{name}")
};
let NodeKind::Image(im) = &b.art.kind else { panic!("{name}: {:?}", b.art.kind_label()) };
im.key.clone()
};
assert_eq!((key("Speck"), key("Speck 2")), ("k".to_string(), "k 2".to_string()));
}
#[test]
fn info_reports_size_resolution_colour_mode_and_link() {
let mut s = session();
+39
View File
@@ -3587,6 +3587,15 @@ proptest! {
}
}
/// A `.ai` whose Art brushed path is damaged or hostile: the brush's object and art, the style's
/// entries and the path, cut short or edited, read and drawn.
#[test]
fn ai_art_brushed_paths_never_panic(cut in 0usize..1_600, edits in prop::collection::vec((0usize..1_600, prop::sample::select(vec!['0', '9', '-', '.', 'e', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', 'u', 'U', 'A', 'Z'])), 0..12)) {
let text = mutate_text(&art_brushed_text(), cut, &edits);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
survive("mutated .ai Art brushed path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// Brush objects of every kind with any parameters, the brush manager's order with any names,
/// and brush art sections nested or cut short: read with the layers, or as a library without
/// them, the brushes kept within their ranges.
@@ -3750,6 +3759,36 @@ fn the_objects_on_path_fuzz_data_opens_live() {
assert!(g.extras().objects_on_path.is_some(), "{g:?}");
}
/// Editing data with an Art brush, its art, and a path with its drawn look and the brush in its art
/// style, as the format writes them.
fn art_brushed_text() -> String {
use vectorcraft_testkit::ai;
let tool = "Adobe ArtOnPath Brush Tool";
let object = format!(
"%AI8_BeginPluginObject\n({tool})\n(R)\n(2 / A/ / / / / 0 0/ 5 0 0 0 / 1) -\n( 1 1 0 1 0 0 0 1 1 0 0 0 0 1) .\n%AI8_EndPluginObject\n"
);
let art = "%AI8_BeginBrushPattern\n(A)\n0 A\n0 g\n0 0 m\n40 0 L\n40 8 L\nf\n%AI8_EndBrushPattern\n";
let style = format!(
"%AI9_BeginArtStyles\n/KnownStyle :\n(S) /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({tool}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
0 /Bool (Proportional) ,\n0 /Bool (Segmented) ,\n1 /Real (Scaling) ,\n1 /Real (MinScaling) ,\n1 /Real (MaxScaling) ,\n1 /Int (Adjustment) ,\n1 /Bool (FoldAdjustment) ,\n1 /Int (Colorization) ,\n\
/StrokeStyle : 0 R\n0 0 0 1 K\n0 1 0 0 0 Xy\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n/GObjRef : (R) ({tool}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n"
);
let path = "0 Ae\nu\n0 g\n10 10 m\n90 10 L\n90 30 L\nf\nU\n0 0 Xd\n6 () XW\n%_10 20 m\n%_90 20 L\n%_90 80 L\n%_n\n1 (S) XW\n";
ai::editing_data(100.0, 100.0, &format!("{object}{art}{style}{}", ai::layer("L", path)))
}
/// The data [`ai_art_brushed_paths_never_panic`] damages, as it is, opens with its path live and the
/// brush on its stroke.
#[test]
fn the_art_brushed_fuzz_data_opens_live() {
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&art_brushed_text())].concat();
// Trusted: the empty page it is read beside isn't compared with its art.
let (doc, warnings, _) = vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0);
let n = doc.layers.first().and_then(|l| l.children()).and_then(|c| c.first()).unwrap_or_else(|| panic!("{warnings:?}"));
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("R"), "{warnings:?} {n:?}");
}
/// The ids of the objects of an Objects on Path group in the fuzz cases.
const ON_PATH_IDS: [&str; 5] = ["f-0001", "f-0002", "f-0003", "f-0004", "f-0005"];
+129 -32
View File
@@ -19,12 +19,20 @@
//! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`,
//! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the
//! page).
//! - A path with an Art brush is a stroke in its art style too: the filter
//! `(Adobe ArtOnPath Brush Tool)`, its stroke's paint and weight (the weight scales the art across
//! the path), the brush by name, and its settings for this path as entries: `Scaling`,
//! `MinScaling` and `MaxScaling` (the width as a factor), `Colorization`, `Proportional`,
//! `Segmented` with `StartSegmentLength` and `EndSegmentLength`, `UpsideDown`, `Counterclockwise`,
//! `ScaleYDependsOn`, `Spacing`, `Adjustment` and `FoldAdjustment` (1 is
//! [`vectorcraft_brush::ArtBrush`]'s `overlap: false`).
//! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's
//! section names (one for Scatter, five for Art and Pattern, empty where there is none, each
//! after a space for Art and Pattern), more fields, and last 15 numbers.
//! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the
//! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the
//! rotation follows the path (14).
//! - Art: 1 Tints (2).
//! - Pattern: its scale as a factor (3).
//! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle
//! thickness, density, paint opacity and bristle length as fractions and the size in
@@ -32,14 +40,16 @@
//! - The art is a `%AI8_BeginBrushPattern` section: its name as a string, then the art (read by
//! [`super::read`]).
//!
//! What isn't read keeps its default: the Art brush's options, the pattern brush's tiles (which
//! slot is which tile isn't known) and every brush's colorization, which is left at None so the
//! art keeps its own colors.
//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles
//! (which slot is which tile isn't known) and the colorization of the other brushes, which is left
//! at None so the art keeps its own colors.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use kurbo::Affine;
use vectorcraft_brush::{ArtBrush, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
use vectorcraft_doc::Node;
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use super::lex::{Lexer, Tok};
use super::obj;
@@ -100,10 +110,13 @@ pub(super) enum DraftKind {
brush: Scatter,
art: String,
},
/// The art is the section named `art`.
/// The art is the section named `art`. `plain`: the brush stretches its art along the path,
/// unflipped, at 100% in Tints (see [`art_is_plain`]), so [`Self::Art`]'s `brush` draws as the
/// file's brush does.
Art {
brush: ArtBrush,
art: String,
plain: bool,
},
/// The five art sections the brush names, in its order (which is which tile isn't known), and
/// its scale (%).
@@ -204,17 +217,65 @@ pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3
Calligraphic { angle, roundness, size, variation, modes: Some(modes) }
}
/// Does the brush `draft` draw as `c`, the settings a path's art style gives it? A path whose
/// The settings a path's art style gives its stroke's brush.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum PathBrush {
Calligraphic(Calligraphic),
/// An Art brush whose art stretches along the path, at the brush's own width, in Tints.
Art,
}
/// Does an Art brush draw `art` as the file does? It bends paths in solid colors: other objects
/// are left out of the pieces, a gradient or pattern stays where it was and takes no color from the
/// stroke, an effect isn't bent with its path, a stroke's outline isn't stretched along it, and art
/// with no length along the path draws nothing.
pub(super) fn art_bends(art: &Node) -> bool {
let mut bends = art.geometric_bounds().is_some_and(|b| b.width() > 1e-9);
art.walk(&mut |n| {
bends &= matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. })
&& n.extras().mask.is_none()
&& n.appearance.effects.is_empty()
&& n.appearance.items.iter().all(|i| i.effects().is_empty() && !matches!(i, AppearanceItem::Stroke(s) if !s.paint.is_none()))
&& vectorcraft_doc::swatches::node_paints(n).all(|p| matches!(p, Paint::Solid { .. } | Paint::None));
});
bends
}
/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does.
pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> HashSet<String> {
let names: HashSet<&String> = drafts
.iter()
.filter_map(|d| match &d.kind {
DraftKind::Art { art, .. } => Some(art),
_ => None,
})
.collect();
names.into_iter().filter(|n| art.get(*n).is_some_and(art_bends)).cloned().collect()
}
/// Does the brush `draft` draw as `settings`, the settings a path's art style gives it? A path whose
/// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and
/// has no settings of its own.
pub(super) fn draws_as(draft: &BrushDraft, c: &Calligraphic) -> bool {
let DraftKind::Calligraphic(d) = &draft.kind else { return false };
/// has no settings of its own. `bending`: [`bending_art`].
pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashSet<String>) -> bool {
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
close(d.angle, c.angle)
&& close(d.roundness, c.roundness)
&& close(d.size, c.size)
&& d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b))
&& d.modes() == c.modes()
match (&draft.kind, settings) {
(DraftKind::Calligraphic(d), PathBrush::Calligraphic(c)) => {
close(d.angle, c.angle)
&& close(d.roundness, c.roundness)
&& close(d.size, c.size)
&& d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b))
&& d.modes() == c.modes()
}
(DraftKind::Art { plain: true, art: name, .. }, PathBrush::Art) => bending.contains(name),
_ => false,
}
}
/// Are an Art brush's last 15 numbers those of a brush that stretches its art along the path,
/// unflipped, at 100% in Tints? The numbers aren't read otherwise: any other value keeps its paths'
/// drawn look.
fn art_is_plain(numbers: &[f64; 15]) -> bool {
*numbers == [1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
}
/// The brush named `name` of kind `kind` from its parameters `params`.
@@ -247,7 +308,8 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
})
.collect();
let n = fields.last().and_then(|f| numbers(f)).ok_or(BrushError::Values)?;
let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n.as_slice() else { return Err(BrushError::Values) };
let n: &[f64; 15] = n.as_slice().try_into().map_err(|_| BrushError::Values)?;
let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n;
let art = || tiles.first().cloned().flatten().ok_or(BrushError::NoArt);
match kind {
Tool::Scatter => {
@@ -268,7 +330,10 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
};
DraftKind::Scatter { brush, art: art()? }
}
Tool::Art => DraftKind::Art { brush: ArtBrush { colorization: Colorization::None, ..ArtBrush::default() }, art: art()? },
Tool::Art => {
let colorization = if *p2 == 1.0 { Colorization::Tints } else { Colorization::None };
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain: art_is_plain(n) }
}
_ => {
let tiles: [Option<String>; 5] = tiles.try_into().map_err(|_| BrushError::Values)?;
DraftKind::Pattern { tiles, scale: p3 * 100.0 }
@@ -314,6 +379,43 @@ pub(super) fn art_weight(art: &Node) -> usize {
weight
}
/// Which of `drafts` [`finish_brushes`] makes into brushes, with [`MAX_BRUSH_ART_NODES`] of art.
pub(super) fn kept(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> Vec<bool> {
kept_brushes(drafts, art, MAX_BRUSH_ART_NODES)
}
/// Which of `drafts` become brushes, with at most `budget` of [`art_weight`] copied into them: not
/// a pattern brush, a brush whose art isn't read, a brush whose art would go past `budget`, or a
/// brush named as one kept before it.
pub(super) fn kept_brushes(drafts: &[BrushDraft], art: &HashMap<String, Node>, budget: usize) -> Vec<bool> {
let mut copied = 0usize;
let mut weights: HashMap<&str, usize> = HashMap::new();
let mut names: HashSet<&str> = HashSet::new();
drafts
.iter()
.map(|d| {
let fits = match &d.kind {
_ if names.contains(d.name.as_str()) => false,
DraftKind::Calligraphic(_) | DraftKind::Bristle(_) => true,
DraftKind::Pattern { .. } => false,
DraftKind::Scatter { art: name, .. } | DraftKind::Art { art: name, .. } => art.get_key_value(name).is_some_and(|(key, a)| {
let weight = *weights.entry(key.as_str()).or_insert_with(|| art_weight(a));
let total = copied.saturating_add(weight);
let fits = total <= budget;
if fits {
copied = total;
}
fits
}),
};
if fits {
names.insert(&d.name);
}
fits
})
.collect()
}
/// [`finish_brushes`] with at most `budget` of [`art_weight`] copied into the brushes (each brush
/// that uses a section holds its own copy): a brush whose art would go past it is left out.
pub(super) fn finish_brushes_within(
@@ -322,27 +424,22 @@ pub(super) fn finish_brushes_within(
order: Option<&[(Tool, String)]>,
budget: usize,
) -> (Vec<Brush>, usize) {
let kept = kept_brushes(&drafts, art, budget);
let copy = |name: &str| art.get(name).cloned();
let mut left_out = 0;
let mut copied = 0usize;
let mut weights: HashMap<&str, usize> = HashMap::new();
let mut copy = |name: &str| -> Option<Node> {
let (key, a) = art.get_key_value(name)?;
let weight = *weights.entry(key.as_str()).or_insert_with(|| art_weight(a));
let total = copied.saturating_add(weight);
(total <= budget).then(|| {
copied = total;
a.clone()
})
};
let mut made: Vec<Option<(Tool, Brush)>> = vec![];
for d in drafts {
for (d, kept) in drafts.into_iter().zip(kept) {
let tool = d.kind.tool();
let repeated = made.iter().flatten().any(|(_, b)| b.name == d.name);
let kind = match d.kind {
_ if repeated => None,
_ if !kept => None,
DraftKind::Calligraphic(c) => Some(BrushKind::Calligraphic(c)),
DraftKind::Scatter { brush, art: name } => copy(&name).map(|a| BrushKind::Scatter(Scatter { art: a, ..brush })),
DraftKind::Art { brush, art: name } => copy(&name).map(|a| BrushKind::Art(ArtBrush { art: a, ..brush })),
DraftKind::Art { brush, art: name, .. } => copy(&name).map(|mut a| {
// The side of the section with y up draws on the right of the path, and a brush
// draws art's y (down here) on the left of the path.
a.transform(Affine::scale_non_uniform(1.0, -1.0), false);
BrushKind::Art(ArtBrush { art: a, ..brush })
}),
DraftKind::Pattern { .. } => None,
DraftKind::Bristle(b) => Some(BrushKind::Bristle(b)),
};
+14
View File
@@ -361,6 +361,17 @@ fn swatch_names(data: &[u8]) -> ShownNames {
names
}
/// What the brushes and brush art read so far give a brushed stroke.
#[derive(Default)]
struct BrushesKept {
/// The counts of brushes and art sections when this was worked out.
read: (usize, usize),
/// Which of [`Reader::brush_drafts`] are kept as brushes.
kept: Vec<bool>,
/// The art an Art brush draws as the file does.
bending: HashSet<String>,
}
struct Reader<'a> {
lex: Lexer<'a>,
stack: Vec<V>,
@@ -562,6 +573,8 @@ struct Reader<'a> {
brush_art: HashMap<String, Node>,
/// The brush art sections read so far, repeated names included.
brush_art_sections: usize,
/// What the brushes and brush art read so far give a brushed stroke.
brushes_kept: BrushesKept,
/// Brush objects that weren't read.
brushes_unread: usize,
/// The graph being read (see [`Reader::graph_record`]).
@@ -702,6 +715,7 @@ impl<'a> Reader<'a> {
brush_drafts: Vec::new(),
brush_art: HashMap::new(),
brush_art_sections: 0,
brushes_kept: BrushesKept::default(),
brushes_unread: 0,
graph: None,
multi_mask: None,
+2 -1
View File
@@ -1107,7 +1107,7 @@ mod tests {
assert!(nodes > 0);
let draft = |i: usize| brush::BrushDraft {
name: format!("Brush {i}"),
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string() },
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), plain: false },
};
let drafts: Vec<_> = (0..5).map(draft).collect();
let (kept, left_out) = brush::finish_brushes_within(drafts.clone(), &reader.brush_art, None, 3 * nodes);
@@ -1117,6 +1117,7 @@ mod tests {
// A second brush of the same name is left out without using the budget.
let mut repeated: Vec<_> = (0..2).map(draft).collect();
repeated.insert(1, draft(0));
assert_eq!(brush::kept_brushes(&repeated, &reader.brush_art, 2 * nodes), [true, false, true]);
let (kept, left_out) = brush::finish_brushes_within(repeated, &reader.brush_art, None, 2 * nodes);
assert_eq!((kept.len(), left_out), (2, 1));
}
+73 -6
View File
@@ -5,8 +5,8 @@ use super::types::{
line_width,
};
use super::{
Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap, HashSet, Instance,
Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
Affine, Appearance, AppearanceItem, Arc, BlendMode, BrushesKept, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap,
HashSet, Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
};
impl<'a> Reader<'a> {
@@ -669,6 +669,19 @@ impl<'a> Reader<'a> {
self.note_state();
}
/// Brings [`Reader::brushes_kept`] up to date with the brushes and brush art read so far.
fn note_brushes_kept(&mut self) {
use super::super::brush;
let read = (self.brush_drafts.len(), self.brush_art_sections);
if self.brushes_kept.read != read {
self.brushes_kept = BrushesKept {
read,
kept: brush::kept(&self.brush_drafts, &self.brush_art),
bending: brush::bending_art(&self.brush_drafts, &self.brush_art),
};
}
}
/// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its
/// drawn look before it (all on `%_` lines inside another such object). Other markers end what
/// follows an object.
@@ -705,6 +718,7 @@ impl<'a> Reader<'a> {
return;
}
let space = self.space();
self.note_brushes_kept();
let Some(f) = self.frames.last_mut() else { return };
let n = f.children.len();
let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else {
@@ -730,6 +744,8 @@ impl<'a> Reader<'a> {
names: &self.global_names,
v7_patterns: &self.v7_patterns,
brushes: &self.brush_drafts,
kept: &self.brushes_kept.kept,
bending: &self.brushes_kept.bending,
space,
},
)
@@ -874,6 +890,10 @@ struct StylePaints<'a> {
v7_patterns: &'a HashSet<String>,
/// The brushes the file defines, which a brushed stroke names.
brushes: &'a [super::super::brush::BrushDraft],
/// Which of [`Self::brushes`] are kept as brushes ([`super::super::brush::kept`]).
kept: &'a [bool],
/// The art an Art brush draws as the file does ([`super::super::brush::bending_art`]).
bending: &'a HashSet<String>,
space: Affine,
}
@@ -1038,11 +1058,21 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
}
},
StyleItem::Stroke { brush, .. } => {
// A brushed stroke draws as its look only with the brush the file defines (the
// first of its name, as the Brushes panel keeps it) at the object's settings.
// A brushed stroke draws as its look only with the brush the Brushes panel keeps
// under its name, at the object's settings, on a path, a compound path or type (an
// image draws no stroke). An Art brush is read on a path or a compound path with
// no effects.
if let Some((name, settings)) = brush {
let draft = paints.brushes.iter().find(|d| &d.name == name)?;
if !super::super::brush::draws_as(draft, settings) {
let (draft, _) = paints.brushes.iter().zip(paints.kept).find(|(d, kept)| **kept && &d.name == name)?;
if !super::super::brush::draws_as(draft, settings, paints.bending) {
return None;
}
let art = matches!(settings, super::super::brush::PathBrush::Art);
let on_a_path = matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
if !on_a_path && (art || !slot) {
return None;
}
if art && !st.effects.is_empty() {
return None;
}
}
@@ -1228,6 +1258,43 @@ mod tests {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(fill, Paint::None, 0.0)))
}
/// An image draws no stroke, so an image with a brush in its art style keeps its drawn look.
#[test]
fn an_image_with_a_brush_keeps_its_drawn_look() {
use super::super::super::brush::{Tool, parse_tool_object};
let tool = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's name in the files
let text = format!(
"%AI9_BeginArtStyles\n/KnownStyle :\n(Inked) /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({tool}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
/StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
6 /Real (Diameter) ,\n2 /Real (DiameterVariation) ,\n2 /Int (DiameterDependsOn) ,\n\
40 /Real (Roundness) ,\n10 /Real (RoundnessVariation) ,\n5 /Int (RoundnessDependsOn) ,\n\
30 /Real (Angle) ,\n180 /Real (AngleVariation) ,\n6 /Int (AngleDependsOn) ,\n0 /Int (AngleRelativeTo) ,\n\
/GObjRef : (N) ({tool}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n"
);
let styles = crate::import::ai::style::art_styles(text.as_bytes());
let st = &styles["Inked"];
let brushes = [parse_tool_object(Tool::Calligraphic, "N", "1 4 8 30 50 -150 210 2 5 6 0").unwrap()];
let (gradients, patterns, names, v7_patterns, bending) =
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new());
let paints = StylePaints {
gradients: &gradients,
patterns: &patterns,
names: &names,
v7_patterns: &v7_patterns,
brushes: &brushes,
kept: &[true],
bending: &bending,
space: Affine::IDENTITY,
};
let look = Node::group(NodeId(1), vec![square(2, Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))]);
assert!(styled(&square(3, Paint::None), &look, st, &paints).is_some(), "a path takes the style");
let image =
ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
assert!(styled(&Node::new(NodeId(4), NodeKind::Image(Box::new(image))), &look, st, &paints).is_none());
}
#[test]
fn data_stops_at_its_end_or_a_marker_of_the_section() {
let section = Some(("BeginTextDocument", "EndTextDocument"));
+1 -1
View File
@@ -316,7 +316,7 @@ pub(super) const IGNORED: &[&str] = &[
"Ar",
"Xs",
"Xt",
// A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path.
// Five indexed curves of samples along a brushed path (`(…) Xt n XD`), on its hidden source path.
"XD",
"Xi",
"XI",
+91 -10
View File
@@ -32,12 +32,12 @@
//! 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 stroke with a Calligraphic brush is its own basic filter, `(Adobe Calligraphic Brush Tool)`, with
//! its stroke's style and the brush's settings for the object as entries (see
//! [`super::brush`]).
//! 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`]).
//!
//! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills
//! with a pattern or gradient too; strokes with a Calligraphic brush too), each with its own
//! with a pattern or gradient too; strokes with a Calligraphic or Art brush too), each with its own
//! transparency and effects, and the effects of
//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and
//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the
@@ -50,6 +50,7 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile};
use vectorcraft_effects::is_pathfinder;
use super::brush::PathBrush;
use super::lex::{Lexer, Tok};
use super::obj::V;
use super::paint;
@@ -78,6 +79,7 @@ const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's nam
const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files
const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
const ART_BRUSH: &str = "Adobe ArtOnPath Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
const CHAIN: &str = "Chain Style Filter";
const STACK: &str = "Stack Style Filter";
@@ -209,8 +211,8 @@ pub(super) enum StyleItem {
miter: f64,
dash: Option<Dash>,
profile: Option<WidthProfile>,
/// A Calligraphic brush: its name, and its settings for this object.
brush: Option<(String, vectorcraft_brush::Calligraphic)>,
/// A brush: its name, and its settings for this object.
brush: Option<(String, PathBrush)>,
},
}
@@ -1139,6 +1141,7 @@ fn paint_item(f: &Filter) -> Result<Option<StyleItem>, ()> {
_ => Err(()),
},
CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()),
ART_BRUSH => art_stroke(f).map(Some).ok_or(()),
_ => Err(()),
}
}
@@ -1170,7 +1173,85 @@ fn calligraphic_stroke(f: &Filter) -> Option<StyleItem> {
miter: f.miter,
dash: None,
profile: None,
brush: Some((name.clone(), super::brush::calligraphic(values, variation, codes))),
brush: Some((name.clone(), PathBrush::Calligraphic(super::brush::calligraphic(values, variation, codes)))),
})
}
/// The entries an Art brush's filter may hold, with the settings [`art_stroke`] reads. Any other
/// entry keeps the drawn look, a variable width's among them.
const ART_ENTRIES: [&str; 18] = [
"StrokeStyle",
"DisplayString",
"Brush",
"Proportional",
"Segmented",
"UpsideDown",
"Counterclockwise",
"ScaleYDependsOn",
"Spacing",
"StartSegmentLength",
"EndSegmentLength",
"Adjustment",
"FoldAdjustment",
"Scaling",
"MinScaling",
"MaxScaling",
"Colorization",
"Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files
];
/// An Art brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a color, names
/// no Art brush, holds an entry that isn't read, or its settings aren't those of art that stretches
/// along the path, unflipped, at the brush's own width (`Scaling` 1), in Tints (`Colorization` 1),
/// with `FoldAdjustment` 1, which is [`vectorcraft_brush::ArtBrush`]'s `overlap: false`: its drawn
/// look stays.
fn art_stroke(f: &Filter) -> Option<StyleItem> {
if !f.paints.0
|| f.gradient
|| f.pattern.is_some()
|| f.repeated
|| !f.arrays.is_empty()
|| !f.kinds.keys().all(|k| ART_ENTRIES.contains(&k.as_str()))
{
return None;
}
let (name, plugin) = f.plugin_object.as_ref()?;
if plugin != ART_BRUSH {
return None;
}
let off = |key: &str, kind: Kind| matches!(entry(f, key, kind), Ok(None | Some(0.0)));
let plain = off("Proportional", Kind::Bool)
&& off("Segmented", Kind::Bool)
&& off("UpsideDown", Kind::Int)
&& off("Counterclockwise", Kind::Int)
&& off("ScaleYDependsOn", Kind::Int)
&& off("Spacing", Kind::Real)
&& off("StartSegmentLength", Kind::Real)
&& off("EndSegmentLength", Kind::Real);
if !plain || entry(f, "Adjustment", Kind::Int).ok()?.unwrap_or(1.0) != 1.0 || entry(f, "FoldAdjustment", Kind::Bool).ok()?.unwrap_or(1.0) != 1.0 {
return None;
}
let one = |key: &str| entry(f, key, Kind::Real).is_ok_and(|v| v.unwrap_or(1.0) == 1.0);
if entry(f, "Scaling", Kind::Real).ok()?? != 1.0 || !one("MinScaling") || !one("MaxScaling") {
return None;
}
if entry(f, "Colorization", Kind::Int).ok()?? != 1.0 {
return None;
}
let paint = f.stroke.clone()?;
// The tinted pieces are process colors, so a swatch's ink would be lost.
if matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
return None;
}
Some(StyleItem::Stroke {
paint,
width: f.width,
cap: f.cap,
join: f.join,
miter: f.miter,
dash: None,
profile: None,
brush: Some((name.clone(), PathBrush::Art)),
})
}
@@ -1214,7 +1295,7 @@ impl Builder<'_> {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => {}
FILL | STROKE | CALLIGRAPHIC => match paint_item(c) {
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) {
Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true,
Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true,
_ => {}
@@ -1228,7 +1309,7 @@ impl Builder<'_> {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => self.plain = false,
FILL | STROKE | CALLIGRAPHIC => {
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => {
paints += 1;
look.visible &= c.visible;
match paint_item(c) {
@@ -1334,7 +1415,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
let f = &filters[id];
match f.name.as_str() {
STACK if f.compound => b.stack(id, &mut st),
FILL | STROKE | CALLIGRAPHIC | CHAIN => {
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH | CHAIN => {
if let Some((item, look)) = b.item(id) {
st.items.push(item);
st.looks.push(look);
+295 -1
View File
@@ -7434,7 +7434,7 @@ fn a_files_brushes_come_in_with_their_values_in_the_panels_order() {
assert_eq!(s.art.geometric_bounds().map(|b| (b.width(), b.height())), Some((4.0, 4.0)), "the art of its section");
let BrushKind::Art(a) = brush_named(&lib, "Ribbon") else { panic!("art") };
assert!(a.art.geometric_bounds().is_some(), "the art of its section");
assert_eq!(a.colorization, vectorcraft_brush::Colorization::None, "the art keeps its colors");
assert_eq!(a.colorization, vectorcraft_brush::Colorization::Tints, "its code");
let BrushKind::Bristle(b) = brush_named(&lib, "Mop") else { panic!("bristle") };
let got = [b.stiffness, b.thickness, b.density, b.opacity, b.length, b.size];
let want = [60.0, 40.0, 30.0, 80.0, 150.0, 2.0 * 72.0 / 25.4];
@@ -7668,6 +7668,300 @@ fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
}
}
/// The entries of an Art brush's filter for a path that stretches its art, at the brush's own width.
const STRETCHED: [(&str, &str, &str); 12] = [
("Proportional", "0", "Bool"),
("Segmented", "0", "Bool"),
("Scaling", "1", "Real"),
("MinScaling", "1", "Real"),
("MaxScaling", "1", "Real"),
("ScaleYDependsOn", "0", "Int"),
("Spacing", "0", "Real"),
("UpsideDown", "0", "Int"),
("Counterclockwise", "0", "Int"),
("Adjustment", "1", "Int"),
("FoldAdjustment", "1", "Bool"),
("Colorization", "1", "Int"),
];
/// The style `Inked`: a stroke in red of weight `weight` with the Art brush `brush`, with the entries
/// `entries` and `more` after them.
fn art_style(brush: &str, weight: f64, entries: &[(&str, &str, &str)], more: &str) -> String {
let entries: String = entries.iter().map(|(key, value, kind)| format!("{value} /{kind} ({key}) ,\n")).collect();
format!(
"%AI9_BeginArtStyles\n/KnownStyle :\n(Inked) /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({ART_BRUSH}) 1 0 /Filter ,\n1 /Visible ,\n(Art Brush) /Title ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\
{entries}{more}/StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 1 0 0 0 Xy\n0 J 0 j {weight} w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
({brush}) /UnicodeString (DisplayString) ,\n/GObjRef : ({brush}) ({ART_BRUSH}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
/FillStyle : 0 O\n0 0 0 0 k\n0 0 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
;\n /Part ,\n;\n%AI9_EndArtStyles\n"
)
}
/// A brush's art named `name`: a wide block with a narrow one on top of it (art space: y up).
fn lopsided_art(name: &str) -> String {
format!(
"%AI8_BeginBrushPattern\n({name})\n0 A\n0 g\n{}f\n{}f\n%AI8_EndBrushPattern\n",
rect_path(0.0, 0.0, 40.0, 4.0),
rect_path(0.0, 4.0, 10.0, 8.0)
)
}
/// A path drawn with an Art brush comes in as the path, its stroke in that brush at its weight (the
/// weight scales the art across the path), not as its drawn look. The art runs along the path as
/// the file draws it: the side of its section with y up goes to the right of the path's direction.
#[test]
fn a_path_with_an_art_brush_comes_in_brushed() {
let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let more = "15 /Int (Adobe Effect Expand Before Version) ,\n";
let r = read_ok(&format!("{def}{}{}", art_style("Ribbon", 2.0, &STRETCHED, more), layer(&brushed_path())));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, not its look: {a:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
let st = a[0].appearance.stroke().unwrap();
assert_eq!((st.brush.as_deref(), st.width, st.paint.color()), (Some("Ribbon"), 2.0, Some(Color::rgb(1.0, 0.0, 0.0))));
let lib = vectorcraft_brush::library(&r.doc);
let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("art") };
assert_eq!((brush.width, brush.colorization), (100.0, vectorcraft_brush::Colorization::Tints));
// Along a straight line, y down: the art is 8 tall at weight 1, its narrow block below the wide one.
let line = kurbo::BezPath::from_vec(vec![kurbo::PathEl::MoveTo(Point::new(0.0, 0.0)), kurbo::PathEl::LineTo(Point::new(100.0, 0.0))]);
let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &line, st);
let b = pieces.iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!((b.height() - 16.0).abs() < 1e-3 && (b.width() - 100.0).abs() < 1e-3, "weight 2 doubles the art across, and it stretches along: {b:?}");
assert!((b.y0 + 8.0).abs() < 1e-3, "{b:?}");
let filled = |x: f64, y: f64| {
pieces.iter().any(|n| {
let mut hit = false;
n.walk(&mut |c| hit |= c.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), Point::new(x, y)) != 0));
hit
})
};
assert!(filled(50.0, -4.0), "the wide block is above the line");
assert!(filled(10.0, 4.0) && !filled(50.0, 4.0), "the narrow block is below it");
}
/// A fill under the brush stays on the path.
#[test]
fn a_filled_path_with_an_art_brush_comes_in_brushed() {
let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let style = art_style("Ribbon", 1.0, &STRETCHED, "").replace("0 0 0 0 k\n0 0 Xd", "0 0 1 0 k\n0 1 Xd");
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
let a = art(&r.doc);
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}");
assert_eq!(a[0].appearance.items.len(), 2, "{:?}", a[0].appearance);
assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"));
}
/// A compound path with an Art brush comes in as the compound path, its stroke in that brush.
#[test]
fn a_compound_path_with_an_art_brush_comes_in_brushed() {
let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let object = format!(
"0 Ae\nu\n{RED}{}f\nU\n0 0 Xd\n6 () XW\n%_*u\n%_20 20 m\n%_100 20 L\n%_n\n%_20 60 m\n%_100 60 L\n%_n\n%_*U\n1 (Inked) XW\n",
rect_path(20.0, 18.0, 100.0, 62.0)
);
let r = read_ok(&format!("{def}{}{}", art_style("Ribbon", 1.0, &STRETCHED, ""), layer(&object)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Compound { .. }), "{a:#?}");
assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"));
}
/// An Art brush draws only art of paths, compound paths and groups in solid fills: not an image, a
/// mask, an effect, a gradient or pattern, a stroke, or art with no length along the path.
#[test]
fn an_art_brush_bends_only_paths_in_solid_fills() {
use vectorcraft_color::gradient::{Gradient, GradientPaint, GradientStop};
use vectorcraft_doc::{Effect, ImageObject, NodeId, OpacityMask};
let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let r = read_ok(&format!("{def}{}", layer(&brushed_path())));
let lib = vectorcraft_brush::library(&r.doc);
let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("art") };
let plain = &brush.art;
assert!(brush::art_bends(plain), "{plain:#?}");
let with = |change: &dyn Fn(&mut Node)| {
let mut n = plain.clone();
change(&mut n);
n
};
let image = ImageObject { key: "k".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)];
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let cases = [
("an image", Node::new(NodeId(90), NodeKind::Image(Box::new(image)))),
("a mask", with(&|n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(plain.clone(), true))))),
("an effect", with(&|n| n.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: Default::default(), visible: true }))),
(
"an effect on a fill",
with(&|n| {
n.appearance.set_fill(Paint::solid(Color::BLACK));
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.effects.push(Effect { id: "stylize.dropShadow".into(), params: Default::default(), visible: true });
}
}),
),
("a gradient", with(&|n| n.appearance.set_fill(Paint::Gradient(Box::new(gradient.clone()))))),
("a stroke", with(&|n| n.appearance.set_stroke(Paint::solid(Color::BLACK)))),
];
let wrong: Vec<&str> = cases.iter().filter(|(_, art)| brush::art_bends(art)).map(|(why, _)| *why).collect();
assert!(wrong.is_empty(), "{wrong:?}");
}
/// An Art brushed path keeps its drawn look when the brush wouldn't draw it the way the file does:
/// its settings aren't those of art that stretches along the path at the brush's own width and
/// color, the brush's own aren't, its art isn't read, or the brush isn't one the panel keeps.
#[test]
fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
let good = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let with = |key: &str, value: &str| -> Vec<(String, String, String)> {
STRETCHED.iter().map(|(k, v, t)| ((*k).to_string(), if *k == key { value.to_string() } else { (*v).to_string() }, (*t).to_string())).collect()
};
let style_with = |key: &str, value: &str| {
let owned = with(key, value);
let entries: Vec<(&str, &str, &str)> = owned.iter().map(|(k, v, t)| (k.as_str(), v.as_str(), t.as_str())).collect();
art_style("Ribbon", 1.0, &entries, "")
};
let plain_style = art_style("Ribbon", 1.0, &STRETCHED, "");
let flipped = "2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 1 1 0 1 1 0 0 1 1 0 0 0 0 1";
let wide = "2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 1 2 0 1 0 0 0 2 2 0 0 0 0 1";
let own_colors = "2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 0 1 0 1 0 0 0 1 1 0 0 0 0 1";
let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n";
let patterned = format!(
"%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n%AI8_EndBrushPattern\n",
rect_path(0.0, 0.0, 4.0, 4.0)
);
let malformed: Vec<(&str, &str, &str)> = STRETCHED.iter().map(|&(k, v, t)| if k == "Segmented" { (k, "1", "Real") } else { (k, v, t) }).collect();
let cases = [
("a switch written as another kind", good.clone(), art_style("Ribbon", 1.0, &malformed, "")),
("art in a pattern", format!("{dots}{patterned}{}", plugin_object(ART_BRUSH, "Ribbon", RIBBON)), plain_style.clone()),
("tints of a swatch's ink", good.clone(), plain_style.replace("0 1 1 0 1 0 0 XA", "0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK")),
(
"art with no length",
format!(
"%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 g\n0 0 m\n0 40 L\nf\n%AI8_EndBrushPattern\n{}",
plugin_object(ART_BRUSH, "Ribbon", RIBBON)
),
plain_style.clone(),
),
(
"art with a stroke",
format!(
"%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 G\n2 w\n{}S\n%AI8_EndBrushPattern\n{}",
rect_path(0.0, 0.0, 40.0, 8.0),
plugin_object(ART_BRUSH, "Ribbon", RIBBON)
),
plain_style.clone(),
),
(
"a width other than the brush's 100%",
format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)),
art_style(
"Ribbon",
1.0,
&STRETCHED.map(|e| if matches!(e.0, "Scaling" | "MinScaling" | "MaxScaling") { (e.0, "2", e.2) } else { e }),
"",
),
),
(
"a brush that keeps its art's colors",
format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)),
art_style("Ribbon", 1.0, &STRETCHED.map(|e| if e.0 == "Colorization" { (e.0, "0", e.2) } else { e }), ""),
),
("an entry that isn't read", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "1 /Int (SomeOtherSetting) ,\n")),
(
"an effect on the path",
good.clone(),
plain_style.replace(
";\n /Part ,\n;\n%AI9_EndArtStyles",
";\n /Part ,\n/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n2 /Int (blnd) ,\n0.75 /Real (opac) ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n; /Dict ;\n /Part ,\n;\n%AI9_EndArtStyles",
),
),
("proportional", good.clone(), style_with("Proportional", "1")),
("between guides", good.clone(), style_with("Segmented", "1")),
("its own width", good.clone(), style_with("Scaling", "2")),
("a width that varies", good.clone(), style_with("MaxScaling", "2")),
("a width on the pen", good.clone(), style_with("ScaleYDependsOn", "2")),
("spacing", good.clone(), style_with("Spacing", "1")),
("flipped", good.clone(), style_with("UpsideDown", "1")),
("flipped the other way", good.clone(), style_with("Counterclockwise", "1")),
("another adjustment", good.clone(), style_with("Adjustment", "2")),
("no fold adjustment", good.clone(), style_with("FoldAdjustment", "0")),
("its own colorization", good.clone(), style_with("Colorization", "0")),
("tints and shades", good.clone(), style_with("Colorization", "2")),
("hue shift", good.clone(), style_with("Colorization", "3")),
("start of a guide", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "5 /Real (StartSegmentLength) ,\n")),
("variable width", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "2 /Int (BSWidthSource) ,\n")),
(
"width arrays",
good.clone(),
art_style(
"Ribbon",
1.0,
&STRETCHED,
"/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n/Array :\n1 /Real ,\n2 /Real ,\n; (BSCenteredWidthsArray) ,\n",
),
),
("a brush that flips", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", flipped)), plain_style.clone()),
("another brush width", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)), plain_style.clone()),
("art not read", plugin_object(ART_BRUSH, "Ribbon", RIBBON), plain_style.clone()),
("no such brush", good.clone(), art_style("Other", 1.0, &STRETCHED, "")),
];
let mut wrong = vec![];
for (why, def, style) in cases {
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
let lib = vectorcraft_brush::library(&r.doc);
let art_read = why == "art not read" || why == "no such brush" || lib.iter().any(|b| b.name == "Ribbon");
if !matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }) || !r.warnings.iter().any(|w| w.contains("drawn look")) || !art_read {
wrong.push(why);
}
}
assert!(wrong.is_empty(), "these came in live, or their art wasn't read: {wrong:?}");
}
/// The name a stroke's brush has is the panel's, for a Calligraphic brush too: a left-out brush of
/// that name doesn't hide the one kept.
#[test]
fn a_calligraphic_brushed_stroke_takes_the_brush_the_panel_keeps_under_its_name() {
let style = brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0);
let path = layer(&brushed_path());
let r = read_ok(&format!("{}{}{style}{path}", plugin_object(PATTERN_BRUSH, "Six D", FENCE), plugin_object(CALLIGRAPHIC, "Six D", SIX_D)));
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Path { .. }), "the pattern brush is left out");
}
/// The name a stroke's brush has is the panel's: a left-out brush of that name doesn't hide the one
/// kept, and a kept one of another kind isn't drawn as the name's Art brush.
#[test]
fn an_art_brushed_stroke_takes_the_brush_the_panel_keeps_under_its_name() {
let style = art_style("Ribbon", 1.0, &STRETCHED, "");
let path = layer(&brushed_path());
let art_def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let r = read_ok(&format!("{}{art_def}{style}{path}", plugin_object(PATTERN_BRUSH, "Ribbon", FENCE)));
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Path { .. }), "the pattern brush is left out");
let r = read_ok(&format!("{}{art_def}{style}{path}", plugin_object(CALLIGRAPHIC, "Ribbon", NIB)));
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }), "the panel's Ribbon is the Calligraphic brush");
}
/// Type drawn with an Art brush keeps its drawn look under the type, which takes no style.
#[test]
fn type_with_an_art_brush_keeps_its_drawn_look() {
let own: String = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n7 /StoryIndex ,\n;\n".lines().map(|l| format!("%_{l}\n")).collect();
let object = format!(
"0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{RED}{}f\nU\n0 0 Xd\n6 () XW\n{own}1 (Inked) XW\n",
rect_path(20.0, 18.0, 100.0, 62.0)
);
let def = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let r = read_ok(&format!("{def}{}{}", art_style("Ribbon", 1.0, &STRETCHED, ""), layer(&object)));
let a = art(&r.doc);
assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7))), "the look and the type, {a:#?}");
let mut brushed = 0;
r.doc.walk(|n| brushed += usize::from(n.appearance.stroke().is_some_and(|s| s.brush.is_some())));
assert_eq!(brushed, 0, "{:?}", r.warnings);
}
use read::{GRAPH_PLAIN, GRAPH_UNENDED};
/// Editing data in the legacy format (version 7 specification, chapter 2): `%AI5_FileFormat 2.0`,
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -193,10 +193,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
weight, where the object's brush settings are the brush's own and its angle is on the page (#372, from a
user's file of 6D Art Pen strokes: all 455 of its brushed styles), and a Calligraphic brush's Tilt, Bearing and
Rotation (codes 4, 5 and 6) come in as those channels instead of Random. Others keep their drawn look.
Paths with an Art brush open the same way when the brush stretches art of solid-filled paths along the path,
unflipped, at 100% in Tints, and the path has none of its own settings, variable width, effects or a swatch
stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped,
art with strokes, gradients or patterns) and type with an Art brush keep the drawn look.
- **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead
of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a
path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope
of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art
brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope
(undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As
`.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification
instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and
+2 -1
View File
@@ -1111,6 +1111,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, Stylus Wheel, and the pen's tilt, bearing and rotation on Windows, native Wayland and the web.
Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules.
Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush).
- **Pen tilt, bearing and rotation (part of #764):** Calligraphic and Scatter Brush Options offer Tilt, Bearing and Rotation beside Fixed, Random and Pressure. The Paintbrush records along each stroke how far the pen leans, which way, and how far its barrel is turned (kept with the path, saved, and joined when a stroke continues a path). A Calligraphic value on Tilt goes from value − variation with the pen lying flat to value + variation upright; on Bearing or Rotation it adds variation × the angle over 180°, so Angle on Bearing with a Variation of 180° turns the nib with the way the pen leans. A Scatter value goes from its first value to its second over the same ranges. A stroke without that input draws the brush's own value. On Linux under X11 and XWayland the app reads the tilt and an Art Pen's barrel rotation from XInput2, on macOS it reads them from AppKit's tablet events, and `path.freehand` points and `pointer_gesture` events carry them on every platform. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page defines the three options and does not say which way each one runs; that mapping is VectorCraft's own rule. Evidence: `tests::tilt_maps_to_roundness_along_the_stroke`, `tests::bearing_and_rotation_turn_the_nib`, `tests::scatter_values_follow_tilt_and_bearing` (vectorcraft-brush), `tests_brushsym::paintbrush_records_the_pens_tilt_on_the_stroke` (vectorcraft-engine), `tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` and `mac_tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` (vectorcraft).
Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value.
@@ -1222,7 +1223,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`).
`.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`).
Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`).
A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. Pattern brushes and brushes that can't be read are left out with a note; brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`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, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`).
A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482).