Merge pull request #6904 from dotCooCoo/feat/ai-editing-data-s6c

.ai save: the editing copy keeps brushes and brushed paths
This commit is contained in:
BFlat
2026-11-12 23:53:38 +07:00
committed by GitHub
11 changed files with 1659 additions and 28 deletions
+77
View File
@@ -4330,6 +4330,17 @@ proptest! {
survive("mutated .ai art styles", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
survive("mutated .ai art styles, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
}
/// A `.ai` whose brushes, as the writer writes them, are damaged: the brush art with its frame,
/// the brush manager's order, the brushes' parameters, a brushed path's style and its object, cut
/// short or edited.
#[test]
fn ai_written_brushes_never_panic(cut in 0usize..BRUSHES_FUZZ_CHARS, edits in prop::collection::vec((0usize..BRUSHES_FUZZ_CHARS, prop::sample::select(vec!['0', '1', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'n', 'X', 'W'])), 0..12)) {
let text = mutate_text(brushes_text(), cut, &edits);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
survive("mutated .ai brushes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
survive("mutated .ai brushes, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
}
}
/// The range of [`ai_written_styles_never_panic`]'s cuts and edits: past the end of [`styles_text`].
@@ -4442,6 +4453,72 @@ fn the_symbol_library_fuzz_data_opens_with_its_symbols() {
assert_eq!(names, ["S2", "S1"], "{warnings:?}");
}
/// The range of [`ai_written_brushes_never_panic`]'s cuts and edits: past the end of [`brushes_text`].
const BRUSHES_FUZZ_CHARS: usize = 48_000;
/// The editing data VectorCraft writes for a document whose brushes are a Calligraphic brush, a
/// Scatter brush in Tints and an Art brush in Tints (each art a wide block over a narrow one), with a
/// path drawn with the Calligraphic brush and one with the Art brush.
fn brushes_text() -> &'static str {
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
TEXT.get_or_init(|| {
use std::sync::Arc;
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node, NodeId};
use vectorcraft_geom::{Rect, shapes};
let block = |id: u64, r: Rect| {
Arc::new(Node::path(NodeId(id), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)))
};
let art = || Node::group(NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))]);
let nib = Calligraphic {
angle: 30.0,
roundness: 40.0,
size: 6.0,
variation: [20.0, 10.0, 2.0],
modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]),
};
let lib = [
Brush { name: "Nib".into(), kind: BrushKind::Calligraphic(nib) },
Brush { name: "Spray".into(), kind: BrushKind::Scatter(Scatter { art: art(), colorization: Colorization::Tints, ..Scatter::default() }) },
Brush { name: "Ribbon".into(), kind: BrushKind::Art(ArtBrush { art: art(), colorization: Colorization::Tints, ..ArtBrush::default() }) },
];
let mut doc = Document::new(200.0, 100.0);
vectorcraft_brush::store(&mut doc, &lib);
let layer = doc.layers[0].id;
for (brush, y) in [("Nib", 30.0), ("Ribbon", 70.0)] {
let mut line = Node::path(
doc.alloc_id(),
shapes::line((10.0, y).into(), (190.0, y).into()),
Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0),
);
line.appearance.stroke_mut().unwrap().brush = Some(brush.into());
doc.insert(Some(layer), usize::MAX, line).unwrap();
}
let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap();
String::from_utf8(out.program).unwrap()
})
}
/// The data [`ai_written_brushes_never_panic`] damages, as it is, opens with its brushes and its
/// brushed paths live, and the property's cuts and edits reach all of it.
#[test]
fn the_written_brushes_fuzz_data_opens_live() {
let text = brushes_text();
assert!(text.chars().count() < BRUSHES_FUZZ_CHARS, "{}", text.chars().count());
for mark in ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "/GObjRef :", "%AI5_BeginLayer", "\n1 (Anon"] {
assert!(text.replace("\r\n", "\n").contains(mark), "{mark}");
}
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat();
let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
assert!(layered, "{warnings:?}");
let names: Vec<String> = vectorcraft_brush::library(&d).into_iter().map(|b| b.name).collect();
assert_eq!(names, ["Nib", "Spray", "Ribbon"]);
let art = d.layers[0].children().unwrap();
let brushes: Vec<Option<&str>> = art.iter().map(|n| n.appearance.stroke().and_then(|s| s.brush.as_deref())).collect();
assert_eq!(brushes, [Some("Nib"), Some("Ribbon")]);
}
/// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the
/// symbol list and a layer of instances, as the format writes them.
fn symbols_text() -> String {
+19 -3
View File
@@ -133,6 +133,16 @@
//! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that
//! fills by the even-odd rule is written as its drawn look. A style is written only when the
//! reader reads it back as the object's appearance (or the graphic style's).
//! - Brushes, as the reader reads them (`brush.rs`, `style.rs`): the Brushes panel's brushes as
//! plug-in objects in a non-printing block after the symbols (`%AI8_BeginPluginObject`, the
//! plug-in's name, the brush's name, its parameters in strings that `-` continues and `.` ends),
//! after the brush manager's order of them; the art of Scatter and Art brushes in a non-printing
//! block before the symbols (`%AI8_BeginBrushPattern`, its name, a group of an unpainted frame at
//! the art's bounds and the art, or the art alone when it has no width or height), when the
//! document has brushes of its own or an object is written with one.
//! A path or compound path with a brush the reader reads live is written with an art style whose
//! stroke is the brush's filter (`writer::brushes`). A brush is written only when the reader reads
//! it back with its settings.
//!
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
@@ -168,9 +178,10 @@
//! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on
//! layers and opacity masks nested too deep, and, on objects written without an art style, live
//! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to
//! graphic styles on objects not written with them, and every art style on a large canvas, are left
//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads),
//! counted as the reader counts them, is left out.
//! graphic styles on objects not written with them, brushes the reader wouldn't read back, and every
//! art style and brush on a large canvas, are left out; art past
//! [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), counted as the
//! reader counts them, is left out.
//!
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
//! the version numbers of the newest files users sent ([`private_data`]).
@@ -288,6 +299,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re
let mut w = Writer::new(doc, to_art, origin);
// On a large canvas the layers' symbol instances are written as their art.
w.symbols_live = scale == 1.0;
w.brushes_live = scale == 1.0;
w.symbol_doc = symbol_doc;
w.max_objects = max_objects;
w.reserve_swatch_gradients();
@@ -305,6 +317,8 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re
}
// The symbols no instance uses too; their art adds definitions.
w.symbols();
// The brushes, once the layers have said whether an object is written with one.
let (brush_art, brush_objects) = w.brush_blocks();
// The palette first: its gradient and pattern swatches add definitions.
let mut palette = String::new();
w.palette(&mut palette);
@@ -323,7 +337,9 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re
let mut s = header.clone();
s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n");
w.definitions(&mut s);
s.push_str(&brush_art);
w.symbol_definitions(&mut s);
s.push_str(&brush_objects);
w.art_styles(&mut s);
s.push_str(&palette);
w.art_style_list(&mut s);
+792 -2
View File
@@ -747,6 +747,7 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() {
assert_eq!(layer[2].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0))));
}
/// A brushed stroke the reader reads as its drawn look (a Scatter brush's) is written as its brush's art.
#[test]
fn brushed_strokes_are_written_as_their_brush_art() {
let mut doc = Document::new(200.0, 100.0);
@@ -756,11 +757,11 @@ fn brushed_strokes_are_written_as_their_brush_art() {
shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()),
Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 4.0),
);
line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
line.appearance.stroke_mut().unwrap().brush = Some("Dots".into());
doc.insert(Some(layer), usize::MAX, line).unwrap();
let pieces = vectorcraft_brush::stroke_pieces(
&doc,
&vectorcraft_brush::find(&doc, "3 pt. Round").unwrap(),
&vectorcraft_brush::find(&doc, "Dots").unwrap(),
&shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()).to_bezpath(),
doc.layers[0].children().unwrap()[0].appearance.stroke().unwrap(),
);
@@ -895,6 +896,7 @@ fn counted() -> Vec<(&'static str, Document)> {
("styles", styles_document()),
("painted clips", painted_clips()),
("blend", blend_doc()),
("brushes", brushes_document()),
]
}
@@ -6911,6 +6913,28 @@ fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it()
assert!(n.extras().mask.is_some(), "{n:?}");
}
/// An object linked to a graphic style that doesn't fit within the object limit is left out with
/// the limit's warning and the warning about links left out.
#[test]
fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() {
let mut doc = Document::new(200.0, 100.0);
let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) };
let mut g = vectorcraft_doc::GraphicStyle::new("Shadowed", ap.clone());
g.id = 9;
doc.graphic_styles.push(g);
// Room for everything but the object.
let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap();
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap);
n.extras_mut().graphic_style = Some(9);
put(&mut doc, n);
let all = export_ai_editing(&doc, &opts()).unwrap();
assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::STYLE_LINKS), "{:?}", all.warnings);
let out = export_within(&doc, &opts(), room).unwrap();
for w in [writer::OBJECT_LIMIT, writer::STYLE_LINKS] {
assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings);
}
}
/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style
/// of its own: the object reads back without live effects.
#[test]
@@ -7258,6 +7282,772 @@ fn a_large_canvas_writes_every_object_as_its_look() {
assert!((p.x - 10.0).abs() < 1e-2 && (p.y - 10.0).abs() < 1e-2, "{p:?}");
}
// ---------- brushes ----------
/// Brush art: a wide block with a narrow one below it (y down), filled black.
fn brush_art_node() -> Node {
let block =
|id: u64, r: Rect| Arc::new(Node::path(vectorcraft_doc::NodeId(id), shapes::rectangle(r), Appearance::basic(black_fill(), Paint::None, 0.0)));
Node::group(vectorcraft_doc::NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))])
}
/// A Calligraphic brush varying with the barrel's rotation and the pen's pressure, a Scatter brush in
/// Tints, an Art brush in Tints and one that keeps its art's colors.
fn brush_library() -> Vec<vectorcraft_brush::Brush> {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
let art = |colorization| BrushKind::Art(ArtBrush { art: brush_art_node(), colorization, ..ArtBrush::default() });
vec![
Brush {
name: "Nib".into(),
kind: BrushKind::Calligraphic(Calligraphic {
angle: 30.0,
roundness: 40.0,
size: 6.0,
variation: [20.0, 10.0, 2.0],
modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]),
}),
},
Brush {
name: "Spray".into(),
kind: BrushKind::Scatter(Scatter {
art: brush_art_node(),
size: (50.0, 150.0),
spacing: (120.0, 200.0),
scatter: (-25.0, 25.0),
rotation: (-45.0, 30.0),
rotation_relative_to_path: true,
colorization: Colorization::Tints,
modes: Some([Variation::Random, Variation::Fixed, Variation::Pressure, Variation::Random]),
}),
},
Brush { name: "Ribbon".into(), kind: art(Colorization::Tints) },
Brush { name: "Plain ribbon".into(), kind: art(Colorization::None) },
]
}
/// A path stroked red at `width` with the brush `brush`, on the first layer of `doc`.
fn brushed(doc: &mut Document, brush: &str, from: (f64, f64), to: (f64, f64), width: f64) {
let mut line =
Node::path(doc.alloc_id(), shapes::line(from.into(), to.into()), Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), width));
line.appearance.stroke_mut().unwrap().brush = Some(brush.into());
put(doc, line);
}
/// [`brush_library`] and a path with the Calligraphic brush and one with the Art brush in Tints.
fn brushes_document() -> Document {
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &brush_library());
brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0);
brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5);
doc
}
/// Brush art `n` as a tree to compare with [`same_tree`]: each object's kind, its anchors and handles,
/// the fills and strokes that paint (a stroke with its width), and its members in order.
fn art_tree(n: &Node) -> serde_json::Value {
let kind = match &n.kind {
NodeKind::Path { .. } => "path",
NodeKind::Compound { .. } => "compound",
NodeKind::Group { clip: false, .. } => "group",
_ => "other",
};
let anchors: Vec<[f64; 6]> = match &n.kind {
NodeKind::Path { path, .. } => {
path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| [a.h_in.x, a.h_in.y, a.p.x, a.p.y, a.h_out.x, a.h_out.y])).collect()
}
_ => vec![],
};
let paints: Vec<serde_json::Value> = n
.appearance
.items
.iter()
.filter(|i| !i.paint().is_none())
.map(|i| match i {
AppearanceItem::Fill(f) => serde_json::json!(["fill", f.paint]),
AppearanceItem::Stroke(s) => serde_json::json!(["stroke", s.paint, s.width]),
})
.collect();
let members: Vec<serde_json::Value> = n.children().map(|c| c.iter().map(|m| art_tree(m)).collect()).unwrap_or_default();
serde_json::json!({"kind": kind, "anchors": anchors, "paints": paints, "members": members})
}
/// Are trees `a` and `b` ([`art_tree`]) the same but for numbers within the written numbers'
/// precision (1/1000)?
fn same_tree(a: &serde_json::Value, b: &serde_json::Value) -> bool {
use serde_json::Value;
match (a, b) {
(Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap() - y.as_f64().unwrap()).abs() <= 1e-3,
(Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same_tree(p, q)),
(Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_tree(v, w))),
_ => a == b,
}
}
/// The document's brushes come back in the Brushes panel's order with their settings and art: the
/// Calligraphic brush's channels, the Scatter brush's ranges, modes and Tints, the Art brushes'
/// colorizations.
#[test]
fn brushes_are_read_back_with_their_settings() {
use vectorcraft_brush::BrushKind;
let doc = brushes_document();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let text = program(&doc);
assert_eq!(text.matches("%AI8_BeginPluginObject").count(), 5, "the manager's order and four brushes");
assert_eq!(text.matches("%AI8_BeginBrushPattern").count(), 3);
let back = vectorcraft_brush::library(&read_back(&doc));
let lib = brush_library();
assert_eq!(back.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Nib", "Spray", "Ribbon", "Plain ribbon"]);
assert_eq!(back[0], lib[0]);
let (BrushKind::Scatter(got), BrushKind::Scatter(want)) = (&back[1].kind, &lib[1].kind) else { panic!("scatter") };
let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3;
assert!(
near(got.size, want.size) && near(got.spacing, want.spacing) && near(got.scatter, want.scatter) && near(got.rotation, want.rotation),
"{got:?}"
);
assert_eq!((got.modes, got.colorization, got.rotation_relative_to_path), (want.modes, want.colorization, true));
let (g, w) = (art_tree(&got.art), art_tree(&want.art));
assert!(same_tree(&g, &w), "the art without its frame, with its paint and members in order: {g:#} for {w:#}");
for (i, colorization) in [(2, vectorcraft_brush::Colorization::Tints), (3, vectorcraft_brush::Colorization::None)] {
let BrushKind::Art(a) = &back[i].kind else { panic!("art") };
assert_eq!(a.colorization, colorization);
let (g, w) = (art_tree(&a.art), art_tree(&brush_art_node()));
assert!(same_tree(&g, &w), "the narrow block stays below the wide one, and no frame stays: {g:#} for {w:#}");
let BrushKind::Art(want) = &lib[i].kind else { panic!("art") };
assert_eq!(ArtBrushOptions::of(a), ArtBrushOptions::of(want));
}
}
/// An Art brush's options, without its art.
#[derive(Debug, PartialEq)]
struct ArtBrushOptions(serde_json::Value);
impl ArtBrushOptions {
fn of(a: &vectorcraft_brush::ArtBrush) -> Self {
let mut v = serde_json::to_value(a).unwrap();
v.as_object_mut().unwrap().remove("art");
Self(v)
}
}
/// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the
/// path, come back as those paths with the brush on their stroke at its weight, not as their drawn
/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look.
#[test]
fn brushed_paths_are_read_back_brushed() {
let mut doc = brushes_document();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
let back = read_back(&doc);
let art = back.layers[0].children().unwrap();
assert_eq!(art.len(), 2);
for (n, (brush, width)) in art.iter().zip([("Nib", 2.0), ("Ribbon", 1.5)]) {
assert!(matches!(n.kind, NodeKind::Path { .. }), "{brush}: the path, not its look: {n:#?}");
let st = n.appearance.stroke().unwrap();
assert_eq!((st.brush.as_deref(), st.width, st.paint.color()), (Some(brush), width, Some(Color::rgb(1.0, 0.0, 0.0))));
assert!(n.appearance.fill().is_none_or(|f| f.paint.is_none()));
}
let ours = doc.layers[0].children().unwrap();
assert_eq!(art[0].path_data(), ours[0].path_data());
assert_eq!(art[1].path_data(), ours[1].path_data());
brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. }));
}
/// A compound path with the Calligraphic brush comes back as that compound path with the brush on its
/// stroke; a filled ellipse with it comes back with its fill and the brush.
#[test]
fn brushed_compound_and_filled_paths_are_read_back_brushed() {
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &brush_library());
let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 2.0);
stroke.brush = Some("Nib".into());
let ring = ring(&mut doc, Appearance { items: vec![AppearanceItem::Stroke(stroke.clone())], effects: vec![], contents_index: None });
put(&mut doc, ring);
let fill = vectorcraft_doc::FillLayer::new(solid(Color::rgb(0.2, 0.6, 0.3)));
let ap = Appearance { items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None };
let ellipse = Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(20.0, 120.0, 140.0, 180.0)), ap);
put(&mut doc, ellipse);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::STROKES || w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
let (art, ours) = (back.layers[0].children().unwrap(), doc.layers[0].children().unwrap());
assert!(matches!(art[0].kind, NodeKind::Compound { .. }), "the compound path, not its look: {:#?}", art[0]);
assert!(same_tree(&art_tree(&art[0])["members"], &art_tree(&ours[0])["members"]), "{:#?}", art[0]);
assert!(matches!(art[1].kind, NodeKind::Path { .. }), "the ellipse, not its look: {:#?}", art[1]);
assert!(same_tree(&art_tree(&art[1])["anchors"], &art_tree(&ours[1])["anchors"]), "{:#?}", art[1]);
assert_eq!(art[1].appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.2, 0.6, 0.3)));
for n in art {
let st = n.appearance.stroke().unwrap();
assert_eq!((st.brush.as_deref(), st.width), (Some("Nib"), 2.0));
}
}
/// A path with an Art brush in Tints whose art doesn't bend along the path (its art has a painted
/// stroke) comes back as its drawn look, with the stroke warning; the brush itself comes back.
#[test]
fn an_art_brush_whose_art_doesnt_bend_keeps_its_paths_look() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Colorization};
let mut art = brush_art_node();
if let Some(c) = art.children_mut() {
for m in c.iter_mut() {
Arc::make_mut(m).appearance = Appearance::basic(black_fill(), black_fill(), 1.0).into();
}
}
let brush =
Brush { name: "Stroked ribbon".into(), kind: BrushKind::Art(ArtBrush { art, colorization: Colorization::Tints, ..ArtBrush::default() }) };
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, std::slice::from_ref(&brush));
brushed(&mut doc, "Stroked ribbon", (20.0, 120.0), (280.0, 150.0), 1.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Group { .. }));
let lib = vectorcraft_brush::library(&back);
let (BrushKind::Art(got), BrushKind::Art(want)) = (&lib[0].kind, &brush.kind) else { panic!("art") };
assert!(same_tree(&art_tree(&got.art), &art_tree(&want.art)), "{:#?}", got.art);
}
/// Settings the other tests don't use come back: a Calligraphic brush varying with tilt and bearing,
/// a Scatter brush keeping its art's colors whose rotation doesn't follow the path, and brushes whose
/// modes aren't given (the reader gives them, the same ones).
#[test]
fn more_brush_settings_are_read_back() {
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
let tilted = Calligraphic {
angle: -40.0,
roundness: 75.0,
size: 9.0,
variation: [10.0, 5.0, 3.0],
modes: Some([Variation::Tilt, Variation::Bearing, Variation::Random]),
};
let scatter = Scatter {
art: brush_art_node(),
size: (80.0, 80.0),
spacing: (100.0, 100.0),
scatter: (0.0, 0.0),
rotation: (15.0, 15.0),
rotation_relative_to_path: false,
colorization: Colorization::None,
modes: Some([Variation::Tilt, Variation::Bearing, Variation::Fixed, Variation::Rotation]),
};
let lib = vec![
Brush { name: "Tilted".into(), kind: BrushKind::Calligraphic(tilted.clone()) },
Brush { name: "Still".into(), kind: BrushKind::Scatter(scatter.clone()) },
Brush { name: "Unset nib".into(), kind: BrushKind::Calligraphic(Calligraphic { modes: None, ..tilted }) },
Brush { name: "Unset spray".into(), kind: BrushKind::Scatter(Scatter { modes: None, ..scatter }) },
];
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = vectorcraft_brush::library(&read_back(&doc));
assert_eq!(back.len(), lib.len());
let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3;
for (got, want) in back.iter().zip(&lib) {
assert_eq!(got.name, want.name);
match (&got.kind, &want.kind) {
(BrushKind::Calligraphic(g), BrushKind::Calligraphic(w)) => {
assert_eq!(g.modes(), w.modes(), "{}", want.name);
assert_eq!(Calligraphic { modes: None, ..g.clone() }, Calligraphic { modes: None, ..w.clone() }, "{}", want.name);
}
(BrushKind::Scatter(g), BrushKind::Scatter(w)) => {
assert_eq!(g.modes(), w.modes(), "{}", want.name);
assert!(near(g.size, w.size) && near(g.spacing, w.spacing) && near(g.scatter, w.scatter) && near(g.rotation, w.rotation), "{g:?}");
assert_eq!((g.colorization, g.rotation_relative_to_path), (Colorization::None, false), "{}", want.name);
assert!(same_tree(&art_tree(&g.art), &art_tree(&w.art)), "{}: {:#?}", want.name, g.art);
}
_ => panic!("{}: {got:?}", want.name),
}
}
}
/// Brush art without width or height, a straight stroked line, comes back as that line, without a
/// frame or a group around it: a Scatter brush's horizontal line and an Art brush's vertical one.
#[test]
fn brush_art_of_a_straight_line_comes_back_as_the_line() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Scatter};
let line = |id: u64, to: (f64, f64)| {
Node::path(vectorcraft_doc::NodeId(id), shapes::line((0.0, 0.0).into(), to.into()), Appearance::basic(Paint::None, black_fill(), 2.0))
};
let lib = vec![
Brush { name: "Dash".into(), kind: BrushKind::Scatter(Scatter { art: line(1, (40.0, 0.0)), ..Scatter::default() }) },
Brush { name: "Post".into(), kind: BrushKind::Art(ArtBrush { art: line(2, (0.0, 40.0)), ..ArtBrush::default() }) },
];
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = vectorcraft_brush::library(&read_back(&doc));
assert_eq!(back.len(), 2);
for (got, want) in back.iter().zip(&lib) {
let art = |b: &Brush| match &b.kind {
BrushKind::Scatter(s) => s.art.clone(),
BrushKind::Art(a) => a.art.clone(),
_ => panic!("{}", b.name),
};
let (g, w) = (art_tree(&art(got)), art_tree(&art(want)));
assert!(same_tree(&g, &w), "{}: {g:#} for {w:#}", want.name);
}
}
/// Brush art of a path with several subpaths is written as a compound path, which the reader reads as
/// a compound path of one path for each subpath, and is weighed as the reader weighs that against the
/// art it copies into one file's brushes: of two Art brushes whose art is a path of 30,000 squares,
/// the second is left out with a warning, so that what is written is what comes back.
#[test]
fn brush_art_of_a_path_with_subpaths_is_weighed_as_it_is_read() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let squares = |n: u32| {
let mut path = PathData::default();
for i in 0..n {
let x = f64::from(i % 200) * 2.0;
let y = f64::from(i / 200) * 2.0;
path.subpaths.extend(shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths);
}
Node::path(vectorcraft_doc::NodeId(1), path, Appearance::basic(black_fill(), Paint::None, 0.0))
};
let brush = |name: &str, n: u32| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) };
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[brush("Two", 2)]);
let back = vectorcraft_brush::library(&read_back(&doc));
let BrushKind::Art(a) = &back[0].kind else { panic!("art") };
let tree = art_tree(&a.art);
assert_eq!(tree["kind"], "compound", "{tree:#}");
assert_eq!(tree["members"].as_array().map(Vec::len), Some(2), "{tree:#}");
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[brush("Many 1", 30_000), brush("Many 2", 30_000)]);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let names: Vec<String> = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect();
assert_eq!(names, ["Many 1"]);
assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 1);
}
/// Brush art that is a compound path is written as one path of all its members' subpaths, and is
/// weighed as the reader reads that back: a compound path of 24,000 members of two squares each is
/// read as 48,000 paths, within what the reader copies into one file's brushes, so the brush is
/// written without a warning; a compound path with one square comes back as one path.
#[test]
fn brush_art_of_a_compound_path_is_weighed_as_it_is_read() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let square = |i: u32| {
let x = f64::from(i % 400) * 2.0;
let y = f64::from(i / 400) * 2.0;
shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths
};
let compound = |members: u32, per: u32| {
let children = (0..members)
.map(|m| {
let path = PathData::new((0..per).flat_map(|k| square(m * per + k)).collect());
Arc::new(Node::path(vectorcraft_doc::NodeId(2), path, Appearance::basic(black_fill(), Paint::None, 0.0)))
})
.collect();
let mut n = Node::path(vectorcraft_doc::NodeId(1), PathData::default(), Appearance::basic(black_fill(), Paint::None, 0.0));
n.kind = NodeKind::Compound { children, rule: FillRule::NonZero };
n
};
let brush = |name: &str, art: Node| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) };
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[brush("One", compound(1, 1))]);
let back = vectorcraft_brush::library(&read_back(&doc));
let BrushKind::Art(a) = &back[0].kind else { panic!("art") };
assert_eq!(art_tree(&a.art)["kind"], "path", "{:#}", art_tree(&a.art));
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[brush("Many", compound(24_000, 2))]);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = vectorcraft_brush::library(&read_back(&doc));
assert_eq!(back.len(), 1);
let BrushKind::Art(a) = &back[0].kind else { panic!("art") };
assert_eq!(a.art.children().map(Vec::len), Some(48_000));
}
/// A path with a drop shadow and the Calligraphic brush keeps the brush and the effect, and its drawn
/// look doesn't warn about the brushed stroke; a group with a drop shadow whose member has the Scatter
/// brush still warns, as the member is written as its brush's art.
#[test]
fn a_brushed_path_with_effects_keeps_its_brush_without_the_stroke_warning() {
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &brush_library());
let mut line = Node::path(
doc.alloc_id(),
shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()),
Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0),
);
line.appearance.stroke_mut().unwrap().brush = Some("Nib".into());
line.appearance.effects = vec![drop_shadow()];
put(&mut doc, line);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert!(matches!(n.kind, NodeKind::Path { .. }), "the path, not its look: {n:#?}");
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Nib"));
assert_eq!(n.appearance.effects.len(), 1);
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &brush_library());
let mut member = Node::path(
doc.alloc_id(),
shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()),
Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0),
);
member.appearance.stroke_mut().unwrap().brush = Some("Spray".into());
let mut g = group(&mut doc, vec![member], false);
g.appearance.effects = vec![drop_shadow()];
put(&mut doc, g);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
}
/// A brushed stroke that paints nothing isn't kept by an art style: a path with only such a stroke is
/// written without a style, and a document whose brushes are a new document's writes no brushes.
#[test]
fn a_brushed_stroke_that_paints_nothing_writes_no_brushes() {
let mut doc = Document::new(300.0, 200.0);
let mut line =
Node::path(doc.alloc_id(), shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), Appearance::basic(Paint::None, Paint::None, 1.0));
line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
put(&mut doc, line);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let text = program(&doc);
assert!(!text.contains("%AI8_BeginPluginObject"), "no brushes");
assert!(!text.contains("/KnownStyle"), "no art style");
}
/// The lines of `program` that hold brushes and what is drawn with them: the brush art, the plug-in
/// objects, the art styles and the layers.
fn brush_lines(program: &str) -> Vec<&str> {
let mut out = vec![];
let mut inside = false;
for line in program.lines() {
if ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "%%EndSetup"].iter().any(|m| line.starts_with(m)) {
inside = true;
}
if inside {
out.push(line);
}
if ["%AI8_EndBrushPattern", "%AI8_EndPluginObject", "%AI9_EndArtStyles"].iter().any(|m| line.starts_with(m)) {
inside = false;
}
}
out
}
/// A document with brushes and brushed paths is written as the same bytes again, and what the reader
/// reads back is written with the same brushes, art styles and layers.
#[test]
fn brushes_write_the_same_bytes_again() {
let doc = brushes_document();
let first = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program);
let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap();
let first = String::from_utf8(first.program).unwrap();
let (again, first) = (brush_lines(&again), brush_lines(&first));
let differ = again.iter().zip(&first).enumerate().find(|(_, (a, b))| a != b);
assert_eq!(differ, None, "the first line that differs");
assert_eq!(again.len(), first.len());
}
/// Brushes the reader wouldn't read back as they are are left out with a warning, each on its own
/// beside a brush that comes back, and each changed from a brush that comes back by the one setting
/// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose
/// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle
/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that
/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a
/// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its
/// name from a later one.
#[test]
fn brushes_the_reader_cant_read_are_left_out() {
use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Colorization, Direction, PatternBrush, Variation};
let lib = brush_library();
let keep = lib[0].clone();
let (BrushKind::Scatter(scatter), BrushKind::Art(art)) = (&lib[1].kind, &lib[2].kind) else { panic!("library") };
let hue = Colorization::HueShift { key: Color::rgb(1.0, 0.0, 0.0) };
let art_with = |f: &dyn Fn(&mut ArtBrush)| {
let mut a = art.clone();
f(&mut a);
BrushKind::Art(a)
};
let mut painted_group = brush_art_node();
painted_group.appearance = Appearance::basic(black_fill(), Paint::None, 0.0).into();
let empty_path = Node::path(vectorcraft_doc::NodeId(1), PathData::new(vec![]), Appearance::basic(black_fill(), Paint::None, 0.0));
let cases: Vec<(&str, BrushKind)> = vec![
("painted group", art_with(&|a| a.art = painted_group.clone())),
("empty path", BrushKind::Scatter(vectorcraft_brush::Scatter { art: empty_path.clone(), ..scatter.clone() })),
("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))),
("pattern", BrushKind::Pattern(PatternBrush::default())),
("bristle", BrushKind::Bristle(Bristle::default())),
("flipped along", art_with(&|a| a.flip_along = true)),
("flipped across", art_with(&|a| a.flip_across = true)),
("overlap", art_with(&|a| a.overlap = true)),
("width", art_with(&|a| a.width = 50.0)),
("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))),
("direction", art_with(&|a| a.direction = Direction::RightToLeft)),
("proportional", art_with(&|a| a.scale = ArtScale::Proportional)),
("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)),
("art hue shift", art_with(&|a| a.colorization = hue)),
(
"scatter tints and shades",
BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: Colorization::TintsAndShades, ..scatter.clone() }),
),
("scatter hue shift", BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: hue, ..scatter.clone() })),
];
let left_out = |extra: Vec<Brush>, why: &str| {
let mut doc = Document::new(300.0, 200.0);
let mut lib = vec![keep.clone()];
lib.extend(extra);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{why}: {:?}", out.warnings);
assert_eq!(vectorcraft_brush::library(&read_back(&doc)), std::slice::from_ref(&keep), "{why}");
};
for (why, kind) in cases {
left_out(vec![Brush { name: "Other".into(), kind }], why);
}
left_out(vec![Brush { name: "A/B".into(), ..keep.clone() }], "slash");
left_out(vec![Brush { kind: BrushKind::Bristle(Bristle::default()), ..keep.clone() }], "a second brush of the name");
// A Pattern brush named "X" is left out, so a Calligraphic brush named "X" after it comes back. A
// path with "X" is drawn with the Pattern brush, so it comes back as its drawn look.
let mut doc = Document::new(300.0, 200.0);
let x = Brush { name: "X".into(), ..keep.clone() };
vectorcraft_brush::store(&mut doc, &[Brush { name: "X".into(), kind: BrushKind::Pattern(PatternBrush::default()) }, x.clone()]);
brushed(&mut doc, "X", (20.0, 50.0), (280.0, 60.0), 2.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
assert_eq!(vectorcraft_brush::library(&back), [x]);
let n = &back.layers[0].children().unwrap()[0];
assert!(n.appearance.stroke().is_none_or(|s| s.brush.is_none()), "not with the other brush of the name: {n:#?}");
}
/// Brushes past the most the reader reads from one file are left out with a warning: of 4,097
/// Calligraphic brushes, the first 4,096 come back.
#[test]
fn brushes_past_the_readers_count_are_left_out() {
let nib = brush_library().remove(0);
let lib: Vec<vectorcraft_brush::Brush> = (0..4097).map(|i| vectorcraft_brush::Brush { name: format!("Nib {i}"), ..nib.clone() }).collect();
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = vectorcraft_brush::library(&read_back(&doc));
assert_eq!(back.len(), 4096);
assert_eq!(back.last().map(|b| b.name.as_str()), Some("Nib 4095"));
}
/// A brush whose art the editing data would change (a stroke inside the path, written as its
/// outline) is left out, also after an object on the layers was already written with that change.
#[test]
fn a_brush_whose_art_would_change_is_left_out() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let inside = |doc: &mut Document| {
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 8.0)), Appearance::basic(Paint::None, black_fill(), 2.0));
n.appearance.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Inside;
n
};
let mut doc = Document::new(300.0, 200.0);
let on_layer = inside(&mut doc);
put(&mut doc, on_layer);
let keep = brush_library().remove(0);
let art = inside(&mut doc);
vectorcraft_brush::store(
&mut doc,
&[keep.clone(), Brush { name: "Outlined".into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) }],
);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
assert_eq!(vectorcraft_brush::library(&read_back(&doc)), [keep]);
}
/// Brushes past the art the reader copies into one file's brushes are left out with a warning, so
/// that what is written is what comes back: three Art brushes of 100,000 anchors each, of which the
/// reader keeps two.
#[test]
fn brushes_past_the_readers_art_limit_are_left_out() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let heavy = |id: u64| {
let points: Vec<vectorcraft_geom::Point> =
(0..100_000).map(|i| vectorcraft_geom::Point::new(f64::from(i) / 1000.0, if i % 2 == 0 { 0.0 } else { 4.0 })).collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
Node::path(vectorcraft_doc::NodeId(id), path, Appearance::basic(black_fill(), Paint::None, 0.0))
};
let lib: Vec<Brush> =
(1..=3).map(|i| Brush { name: format!("Heavy {i}"), kind: BrushKind::Art(ArtBrush { art: heavy(i), ..ArtBrush::default() }) }).collect();
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back: Vec<String> = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect();
assert_eq!(back, ["Heavy 1", "Heavy 2"]);
assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 2);
// The art of the brush left out isn't counted: there is room for it all with room for what is read.
let read = crate::import::objects_read(&out.program).unwrap();
let room = export_within(&doc, &opts(), read).unwrap();
assert!(!room.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{read}: {:?}", room.warnings);
}
/// A brush whose art doesn't fit within the object limit is left out with the limit's warning, and
/// what its art took is given back: a later brush whose art fits comes back.
#[test]
fn a_brush_whose_art_passes_the_object_limit_leaves_room_for_the_next() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
let squares = |n: u64| {
let block = |i: u64| {
let x = i as f64 * 10.0;
Arc::new(Node::path(
vectorcraft_doc::NodeId(i + 1),
shapes::rectangle(Rect::new(x, 0.0, x + 5.0, 5.0)),
Appearance::basic(black_fill(), Paint::None, 0.0),
))
};
Node::group(vectorcraft_doc::NodeId(100), (0..n).map(block).collect())
};
let art = |name: &str, n: u64| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) };
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &[art("Big", 6), art("Small", 2)]);
let all = export_ai_editing(&doc, &opts()).unwrap();
assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings);
let mut small = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut small, &[art("Small", 2)]);
let room = crate::import::objects_read(&export_ai_editing(&small, &opts()).unwrap().program).unwrap();
let out = export_within(&doc, &opts(), room).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings);
assert!(crate::import::objects_read(&out.program).unwrap() <= room);
let mut data = ZSTD_MARKER.to_vec();
data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest));
let back = crate::ai_alone(&data).unwrap().0;
let names: Vec<String> = vectorcraft_brush::library(&back).into_iter().map(|b| b.name).collect();
assert_eq!(names, ["Small"]);
}
/// A graphic style with a brushed stroke, which is left out, takes no room from the layers' objects:
/// a document with a new document's brushes and no brushed path writes no brush art, so with room for
/// what its layers write, every object comes back.
#[test]
fn a_graphic_style_with_a_brush_takes_no_room_from_the_layers() {
let mut doc = Document::new(300.0, 200.0);
for i in 0..3 {
let x = 20.0 + f64::from(i) * 40.0;
let square =
Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(x, 20.0, x + 30.0, 50.0)), Appearance::basic(black_fill(), Paint::None, 0.0));
put(&mut doc, square);
}
let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap();
let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 1.0);
stroke.brush = Some("3 pt. Round".into());
let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None };
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Brushed", ap));
let out = export_within(&doc, &opts(), room).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{room}: {:?}", out.warnings);
let program = String::from_utf8(out.program).unwrap();
assert!(!program.contains("%AI8_BeginBrushPattern"), "no brush art");
}
/// A graphic style whose brushed stroke paints nothing comes back in the Graphic Styles panel.
#[test]
fn a_graphic_style_with_an_unpainted_brushed_stroke_comes_back() {
let mut doc = Document::new(300.0, 200.0);
let mut ap = Appearance::basic(black_fill(), Paint::None, 1.0);
ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Unpainted brush", ap));
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
assert!(read_back(&doc).graphic_style("Unpainted brush").is_some());
}
/// A brush whose art doesn't fit within the object limit after a path was written with it on its
/// stroke is left out with both warnings, and the path comes back as its drawn look.
#[test]
fn a_brushed_path_whose_brush_art_doesnt_fit_comes_back_as_its_look() {
let ribbon = brush_library().remove(2);
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, std::slice::from_ref(&ribbon));
brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5);
let all = export_ai_editing(&doc, &opts()).unwrap();
assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::BRUSHES), "{:?}", all.warnings);
let room = crate::import::objects_read(&all.program).unwrap() - 1;
let out = export_within(&doc, &opts(), room).unwrap();
for w in [writer::OBJECT_LIMIT, writer::BRUSHES] {
assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings);
}
assert!(crate::import::objects_read(&out.program).unwrap() <= room);
let mut data = ZSTD_MARKER.to_vec();
data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest));
let back = crate::ai_alone(&data).unwrap().0;
assert!(vectorcraft_brush::library(&back).is_empty());
let n = &back.layers[0].children().unwrap()[0];
assert!(matches!(n.kind, NodeKind::Group { .. }), "the path's drawn look: {n:#?}");
}
/// An empty Brushes panel comes back empty, and a document with a new document's brushes and no
/// brushed path writes no brushes (the reader gives such a file a new document's).
#[test]
fn an_empty_brushes_panel_comes_back_empty() {
let mut doc = Document::new(300.0, 200.0);
assert!(!program(&doc).contains("%AI8_BeginPluginObject"));
assert_eq!(vectorcraft_brush::library(&read_back(&doc)), vectorcraft_brush::library(&doc));
vectorcraft_brush::store(&mut doc, &[]);
assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty());
}
/// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its
/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle
/// brushes are left out with a warning, so the file opens without them.
#[test]
fn a_new_documents_brush_on_a_path_writes_the_brushes() {
let mut doc = Document::new(300.0, 200.0);
brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round"));
let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"];
let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect();
let lib = vectorcraft_brush::library(&back);
assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), want);
assert_eq!(vectorcraft_brush::defaults().len(), want.len() + left_out.len());
}
/// A path with one of a new document's brushes that doesn't fit within the object limit is left
/// out, and no brushes are written: the file opens with a new document's brushes, all of them.
#[test]
fn a_new_documents_brush_on_a_path_past_the_object_limit_writes_no_brushes() {
let empty = Document::new(300.0, 200.0);
let room = crate::import::objects_read(&export_ai_editing(&empty, &opts()).unwrap().program).unwrap();
let mut doc = Document::new(300.0, 200.0);
brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0);
let out = export_within(&doc, &opts(), room).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings);
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
assert!(!String::from_utf8(out.program).unwrap().contains("%AI8_BeginPluginObject"));
}
/// On a large canvas the brushes are left out with a warning, and brushed paths come back as their
/// drawn look.
#[test]
fn a_large_canvas_leaves_the_brushes_out() {
let mut doc = Document::new(20000.0, 100.0);
vectorcraft_brush::store(&mut doc, &brush_library());
brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
assert!(!program(&doc).contains("%AI8_BeginPluginObject"));
assert!(matches!(read_back(&doc).layers[0].children().unwrap()[0].kind, NodeKind::Group { .. }));
// An empty panel is kept, without a warning.
let mut doc = Document::new(20000.0, 100.0);
vectorcraft_brush::store(&mut doc, &[]);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty());
}
// ---------- variables ----------
/// A 200 × 100 document with variables: a text variable `name` bound to point type, a visibility
+16 -2
View File
@@ -15,6 +15,7 @@ use vectorcraft_pathops::BoolOp;
use crate::ai_ops::{self, unit};
use crate::ps;
mod brushes;
mod images;
mod paints;
mod styles;
@@ -22,6 +23,8 @@ mod symbols;
mod texts;
mod variables;
#[cfg(test)]
pub(super) use brushes::BRUSHES;
pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects};
#[cfg(test)]
pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance};
@@ -51,7 +54,7 @@ pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths tha
pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps";
pub(super) const LIVE: &str =
"blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps";
const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps";
pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps";
pub(super) const ITEM_TRANSPARENCY: &str =
"the opacity and blending modes of single fills and strokes are left out of the editing data for other apps";
pub(super) const LAYER_MASKS: &str = "opacity masks on layers are left out of the editing data for other apps";
@@ -255,6 +258,10 @@ pub(super) struct Writer<'a> {
/// An object's drawn look is being written: no object in it is written with a style, and its
/// effects are the style's.
in_look: bool,
/// The brushes written ([`brushes`]).
brush_set: brushes::Brushes,
/// Brushes may be written; false on a large canvas, where they are left out.
pub(super) brushes_live: bool,
}
impl<'a> Writer<'a> {
@@ -310,6 +317,8 @@ impl<'a> Writer<'a> {
styled: HashMap::new(),
styles: styles::Styles::default(),
in_look: false,
brush_set: brushes::Brushes::default(),
brushes_live: true,
objects: 0,
left_out: 0,
definition_objects: 0,
@@ -1028,7 +1037,12 @@ impl<'a> Writer<'a> {
AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy),
AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy),
AppearanceItem::Stroke(s) => {
self.warn(STROKES);
// In a drawn look a brushed stroke is kept by the object's art style, which is written
// only when the reader reads the brush back from it; a styled group's members are
// written again after the look, and warn there.
if !(self.in_look && s.brush.is_some()) {
self.warn(STROKES);
}
self.wrote_fixed = true;
if self.brush_art(path, s, xy) {
return;
+558
View File
@@ -0,0 +1,558 @@
//! Brushes: the document's brushes as the `.ai` reader reads them ([`crate::import`]'s `.ai` reader,
//! `brush.rs`), and the brush filter of a brushed stroke in an art style (`style.rs`).
//!
//! Each brush is a plug-in object (`%AI8_BeginPluginObject`, the plug-in's name, the brush's name,
//! its parameters as strings that `-` continues and `.` ends, `%AI8_EndPluginObject`), after the
//! brush manager's order of them all, in a non-printing block after the symbols. The art of a
//! Scatter or Art brush is a `%AI8_BeginBrushPattern` section (its name; a group of the art's frame,
//! an unpainted rectangle at the art's geometric bounds, and the art, or the art alone when it has no
//! width or height; its end) in a non-printing block before the symbols. Each brush is checked with
//! the reader's own reading ([`crate::import::read_brush_object`]) before it is written: one the
//! reader would read as another brush is left out.
use std::collections::HashSet;
use std::fmt::Write;
use std::sync::Arc;
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Calligraphic, Colorization, Direction, Scatter, Variation};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, PathData, SubPath};
use super::{Writer, Xy};
use crate::ai_ops;
use crate::ps;
pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps";
/// Most brushes written (the reader's limit).
const MAX_BRUSHES: usize = 4096;
/// Most bytes of a plug-in object's parameters in one of its strings.
const CHUNK: usize = 60;
/// A brush written as a plug-in object.
pub(super) struct BrushOut {
pub brush: Brush,
pub plugin: &'static str,
pub params: String,
/// The art its parameters name, as the reader reads it.
pub art: Option<Node>,
/// Its art section to write ([`Writer::brush_blocks`] writes it).
section: Option<SectionArt>,
/// A path whose art style names it at its own settings is read with the brush on its stroke.
pub live: bool,
}
/// A brush's art section to write: its name, the art and the space it is written in
/// ([`Writer::brush_art_section`]).
type SectionArt = (String, Node, Affine);
/// The brushes written and their art.
#[derive(Default)]
pub(super) struct Brushes {
/// The document's brushes have been worked out.
done: bool,
pub written: Vec<BrushOut>,
/// The art sections named so far.
sections: usize,
/// A brush of the document was left out.
left_out: bool,
/// How many objects were written with a brush on their stroke.
pub live_written: usize,
/// The brushes written as the reader reads them, for checking art styles; made once, when
/// the first style is checked ([`Writer::brushed_style_reads`]).
read: Option<crate::import::BrushesRead>,
}
/// A number with at most six decimals, as brush parameters hold factors.
fn num6(s: &mut String, v: f64) {
let v = if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 };
if v == v.trunc() && v.abs() < 1e15 {
let _ = write!(s, "{}", v as i64);
return;
}
let start = s.len();
let _ = write!(s, "{v:.6}");
let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len();
s.truncate(start + keep);
}
/// `vs` as [`num6`] numbers separated by spaces.
fn nums6(vs: &[f64]) -> String {
let mut s = String::new();
for (i, v) in vs.iter().enumerate() {
if i > 0 {
s.push(' ');
}
num6(&mut s, *v);
}
s
}
/// The code a brush's parameters give how a value varies, in the order of the Brush Options menu.
fn code(v: Variation) -> f64 {
match v {
Variation::Fixed => 0.0,
Variation::Random => 1.0,
Variation::Pressure => 2.0,
Variation::Tilt => 4.0,
Variation::Bearing => 5.0,
Variation::Rotation => 6.0,
}
}
/// The colorization code of Scatter and Art brushes' parameters: `None` for one the reader doesn't read.
fn colorization_code(c: Colorization) -> Option<f64> {
match c {
Colorization::None => Some(0.0),
Colorization::Tints => Some(1.0),
Colorization::TintsAndShades | Colorization::HueShift { .. } => None,
}
}
/// A Calligraphic brush's parameters: 1, the diameter, roundness and angle as (low, high) pairs,
/// the codes of how the diameter, roundness and angle vary, and 0.
fn calligraphic_params(c: &Calligraphic) -> String {
let [angle, roundness, size] = [c.angle, c.roundness, c.size];
let [av, rv, dv] = c.variation;
let [am, rm, dm] = c.modes().map(code);
nums6(&[1.0, size - dv, size + dv, roundness - rv, roundness + rv, angle - av, angle + av, dm, rm, am, 0.0])
}
/// A Scatter brush's parameters, its art the section `art`: `None` for a colorization the reader
/// doesn't read.
fn scatter_params(s: &Scatter, art: &str) -> Option<String> {
let f = |v: f64| v / 100.0;
let m = s.modes().map(code);
let numbers = [
colorization_code(s.colorization)?,
f(s.size.0),
f(s.size.1),
m[0],
f(s.spacing.0),
f(s.spacing.1),
m[1],
f(s.scatter.0),
f(s.scatter.1),
m[2],
s.rotation.0,
s.rotation.1,
m[3],
f64::from(u8::from(s.rotation_relative_to_path)),
0.0,
];
Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers)))
}
/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read
/// (it reads an Art brush that stretches its art along the path from left to right at 100%,
/// unflipped, without overlap, in Tints or keeping its colors).
fn art_params(a: &ArtBrush, art: &str) -> Option<String> {
let plain = a.direction == Direction::LeftToRight
&& matches!(a.scale, ArtScale::Stretch)
&& a.width == 100.0
&& a.width_mode.is_none()
&& !a.flip_along
&& !a.flip_across
&& !a.overlap;
if !plain {
return None;
}
let c = colorization_code(a.colorization)?;
let numbers = [1.0, c, 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];
Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers)))
}
/// Is `art` written as the reader reads it back, within a brush: paths and compound paths with
/// points and groups with members (not clipping, without paint of their own), each opaque in the
/// normal blending mode, without effects or opacity masks, with at most one fill and one plain
/// stroke (no brush, width profile, arrowheads, alignment off the path or dashes fitted to corners)
/// over it, each in a color or none? A path without points isn't written ([`Writer::plain_object`]).
fn plain_art(art: &Node) -> bool {
let mut plain = true;
art.walk(&mut |n| {
let kind = match &n.kind {
NodeKind::Path { path, guide: false, clipping: false, .. } => path.subpaths.iter().any(|sp| !sp.anchors.is_empty()),
NodeKind::Compound { .. } => super::compound_path(n).subpaths.iter().any(|sp| !sp.anchors.is_empty()),
NodeKind::Group { children, clip: false } => !children.is_empty(),
_ => false,
};
let items = &n.appearance.items;
let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count();
let strokes = items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count();
let order = !matches!(items.as_slice(), [AppearanceItem::Stroke(_), AppearanceItem::Fill(_)]);
let quiet = items.iter().all(|i| {
i.effects().is_empty()
&& i.opacity() >= 1.0
&& i.blend() == BlendMode::Normal
&& matches!(i.paint(), Paint::Solid { .. } | Paint::None)
&& !matches!(i, AppearanceItem::Stroke(s) if !vectorcraft_effects::stroke::is_plain(s))
});
// A group's own fills and strokes aren't written.
let group_paint = matches!(n.kind, NodeKind::Group { .. }) && items.iter().any(|i| !i.paint().is_none());
plain &= kind
&& !group_paint
&& fills <= 1
&& strokes <= 1
&& order
&& quiet
&& n.appearance.effects.is_empty()
&& n.extras().mask.is_none()
&& n.opacity >= 1.0
&& n.blend == BlendMode::Normal;
});
plain
}
/// `art` (checked by [`plain_art`]) with its structure as the reader reads it back. A path, or a
/// compound path with the subpaths of all its members as one path ([`super::compound_path`]), is
/// written as one path when it has one subpath with points and as a compound path when it has more
/// ([`Writer::plain_object`]); the reader reads that compound path as one path for each subpath,
/// each with the object's paint.
fn as_read(art: &mut Node) {
let (subpaths, rule) = match &art.kind {
NodeKind::Path { path, rule, .. } => (path.subpaths.clone(), *rule),
NodeKind::Compound { rule, .. } => (super::compound_path(art).subpaths, *rule),
_ => {
if let Some(children) = art.children_mut() {
for c in children.iter_mut() {
as_read(Arc::make_mut(c));
}
}
return;
}
};
let mut subpaths: Vec<SubPath> = subpaths.into_iter().filter(|sp| !sp.anchors.is_empty()).collect();
let path = |sp: SubPath| {
let mut p = Node::path(art.id, PathData::single(sp), Appearance::default());
p.appearance = art.appearance.clone();
p
};
match subpaths.len() {
0 => {}
1 => {
let Some(sp) = subpaths.pop() else { return };
art.kind = path(sp).kind;
if let NodeKind::Path { rule: r, .. } = &mut art.kind {
*r = rule;
}
}
_ => {
let children = subpaths.into_iter().map(|sp| Arc::new(path(sp))).collect();
art.kind = NodeKind::Compound { children, rule };
}
}
}
/// Do brushes `a` and `b` have the same settings, within the written numbers' precision, whatever
/// their art and however their modes are given?
fn same_settings(a: &Brush, b: &Brush) -> bool {
let norm = |b: &Brush| -> serde_json::Value {
let mut b = b.clone();
let none = || Node::group(NodeId(0), vec![]);
match &mut b.kind {
BrushKind::Calligraphic(c) => c.modes = Some(c.modes()),
BrushKind::Scatter(s) => {
s.modes = Some(s.modes());
s.art = none();
}
BrushKind::Art(a) => a.art = none(),
BrushKind::Pattern(_) | BrushKind::Bristle(_) => {}
}
serde_json::to_value(&b).unwrap_or(serde_json::Value::Null)
};
super::styles::near(&norm(a), &norm(b))
}
/// A plug-in object: its plug-in's name, its name, and `params` in strings of at most [`CHUNK`] bytes.
fn plugin_object(plugin: &str, name: &str, params: &str) -> String {
let mut s = format!("%AI8_BeginPluginObject\n{}\n{}\n", ps::bytes(plugin.as_bytes()), ps::bytes(name.as_bytes()));
let chunks: Vec<&[u8]> = params.as_bytes().chunks(CHUNK).collect();
if chunks.is_empty() {
s.push_str("() .\n");
}
for (i, c) in chunks.iter().enumerate() {
let _ = writeln!(s, "{} {}", ps::bytes(c), if i + 1 == chunks.len() { "." } else { "-" });
}
s.push_str("%AI8_EndPluginObject\n");
s
}
impl Writer<'_> {
/// Work out, once, which of the document's brushes are written: each one the reader reads back
/// with its settings. Their art is written with the brushes ([`Self::brush_blocks`]).
pub(super) fn brush_set(&mut self) {
if self.brush_set.done {
return;
}
self.brush_set.done = true;
let doc = self.doc;
let lib = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)).clone();
if !self.brushes_live {
self.brush_set.left_out = !lib.is_empty();
return;
}
// A name is taken by a brush written, as the reader takes it by a brush it keeps. A brush after
// another of its name isn't the one a path of that name is drawn with, so no path is written
// with it on its stroke.
let mut names: HashSet<String> = HashSet::new();
let mut seen: HashSet<String> = HashSet::new();
for b in lib {
let fits = !b.name.contains('/') && !names.contains(&b.name) && self.brush_set.written.len() < MAX_BRUSHES;
let first = seen.insert(b.name.clone());
match fits.then(|| self.brush_out(&b)).flatten() {
Some(mut out) => {
names.insert(b.name.clone());
out.live &= first;
self.brush_set.written.push(out);
}
None => self.brush_set.left_out = true,
}
}
// Read together, as one file's: the reader copies only so much art into one file's brushes.
let objects: Vec<(&str, &str, &str, Option<&Node>)> =
self.brush_set.written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect();
let kept = crate::import::brushes_kept(&objects);
if kept.iter().any(|k| !k) {
self.brush_set.left_out = true;
let mut keep = kept.into_iter();
self.brush_set.written.retain(|_| keep.next().unwrap_or(false));
}
}
/// Brush `b` as it is written, with its art section's name, art and placement
/// ([`Self::brush_art_section`] writes it): `None` when the reader wouldn't read it back.
fn brush_out(&mut self, b: &Brush) -> Option<BrushOut> {
let plugin = crate::import::brush_plugin_name(&b.kind);
let section = format!("Brush Art {}", self.brush_set.sections + 1);
// The art as the reader reads it: a section is read with y down, and an Art brush's art is
// turned over after that (its section's y up is the right of the path).
let (params, art) = match &b.kind {
BrushKind::Calligraphic(c) => (calligraphic_params(c), None),
BrushKind::Scatter(s) => (scatter_params(s, &section)?, Some((s.art.clone(), Affine::FLIP_Y))),
BrushKind::Art(a) => (art_params(a, &section)?, Some((a.art.clone(), Affine::IDENTITY))),
BrushKind::Pattern(_) | BrushKind::Bristle(_) => return None,
};
let read_art = match &art {
Some((a, to_section)) => {
if !plain_art(a) {
return None;
}
let mut read = a.clone();
if *to_section == Affine::IDENTITY {
read.transform(Affine::FLIP_Y, false);
}
as_read(&mut read);
Some(read)
}
None => None,
};
let read = crate::import::read_brush_object(plugin, &b.name, &params, read_art.as_ref())?;
if !same_settings(&read.brush, b) {
return None;
}
if art.is_some() {
self.brush_set.sections += 1;
}
let section = art.map(|(a, to_section)| (section, a, to_section));
Some(BrushOut { brush: b.clone(), plugin, params, art: read_art, section, live: read.live })
}
/// A brush's art section `name`: `art` written with `to_section` taking the document's space to
/// the section's (y up), from the state a definition starts in. Its objects are counted as the
/// reader counts them ([`Self::take_objects`]: the group, the frame and the art). `None` when
/// writing it warns: what it took is given back, and when it didn't fit within the object limit,
/// the limit's warning is kept.
fn brush_art_section(&mut self, name: &str, art: &Node, to_section: Affine) -> Option<String> {
let before = self.charges();
// The art's own warnings, apart from those the document already has (a warning is kept once).
let warnings = std::mem::take(&mut self.warnings);
let saved = (
std::mem::take(&mut self.body),
(self.to_art, self.grad_space, self.canvas),
self.state.take(),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.block_start, self.block_end),
// The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that
// first uses the brush.
(std::mem::take(&mut self.hidden), std::mem::take(&mut self.in_look), std::mem::take(&mut self.in_symbol)),
);
(self.to_art, self.grad_space, self.canvas, self.block_end) = (to_section, to_section, to_section, usize::MAX);
self.state(false, false);
// The art in a group after its frame, an unpainted rectangle around it. Art without width or
// height (a straight line) is written alone: the reader keeps a frame of no area as art.
let frame = art
.geometric_bounds()
.filter(|b| b.width() > 0.0 && b.height() > 0.0)
.and_then(|b| vectorcraft_geom::shapes::rectangle(b).subpaths.first().cloned());
match frame {
Some(frame) if self.take_objects(2) => {
self.line("u\n0 XR");
ai_ops::subpath(&mut self.body, to_section, &frame);
self.line("n");
self.nest += 1;
self.node(art);
self.nest -= 1;
self.line("U");
}
Some(_) => {}
None => {
self.nest += 1;
self.node(art);
self.nest -= 1;
}
}
let body;
(
body,
(self.to_art, self.grad_space, self.canvas),
self.state,
self.gs,
self.clip,
(self.block_start, self.block_end),
(self.hidden, self.in_look, self.in_symbol),
) = saved;
let text = std::mem::replace(&mut self.body, body);
if !std::mem::replace(&mut self.warnings, warnings).is_empty() {
self.give_back(before, &text);
if self.left_out_since(before) {
self.warn(super::OBJECT_LIMIT);
}
return None;
}
self.keep_since(before);
Some(format!("%AI8_BeginBrushPattern\n{}\n{text}%AI8_EndBrushPattern\n", ps::bytes(name.as_bytes())))
}
/// The written brush named `name` that a path can be written with live.
pub(super) fn live_brush(&self, name: &str) -> Option<&BrushOut> {
self.brush_set.written.iter().find(|b| b.live && b.brush.name == name)
}
/// The basic filter of stroke `st` with the brush it names: the brush's plug-in as the filter,
/// its settings for the object, the stroke's style and the brush by name. `None` when the brush
/// isn't written live.
pub(super) fn brush_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> {
let name = st.brush.as_deref()?;
self.brush_set();
let (plugin, kind) = self.live_brush(name).map(|b| (b.plugin, b.brush.kind.clone()))?;
if !matches!(st.paint, Paint::Solid { .. }) || st.overprint {
return None;
}
let ops = self.color_ops(&st.paint, true);
let mut d = String::new();
let title = match &kind {
BrushKind::Calligraphic(c) => {
let modes = c.modes().map(code);
for (key, value, variation, mode) in [
("Diameter", c.size, c.variation[2], modes[2]),
("Roundness", c.roundness, c.variation[1], modes[1]),
("Angle", c.angle, c.variation[0], modes[0]),
] {
let _ = writeln!(d, "{} /Real ({key}) ,", ps::num(value));
let _ = writeln!(d, "{} /Real ({key}Variation) ,", ps::num(variation));
let _ = writeln!(d, "{} /Int ({key}DependsOn) ,", mode as i64);
}
d.push_str("0 /Int (AngleRelativeTo) ,\n");
"(Calligraphic Brush) /Title ,\n"
}
BrushKind::Art(_) => {
d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files
for (key, value, kind) in [
("StartSegmentLength", "0", "Real"),
("EndSegmentLength", "0", "Real"),
("Counterclockwise", "0", "Int"),
("MinScaling", "1", "Real"),
("MaxScaling", "1", "Real"),
("Colorization", "1", "Int"),
("Spacing", "0", "Real"),
("Adjustment", "1", "Int"),
("Proportional", "0", "Bool"),
("Segmented", "0", "Bool"),
("UpsideDown", "0", "Int"),
("FoldAdjustment", "1", "Bool"),
("ScaleYDependsOn", "0", "Int"),
("Scaling", "1", "Real"),
] {
let _ = writeln!(d, "{value} /{kind} ({key}) ,");
}
"(Art Brush) /Title ,\n"
}
_ => return None,
};
super::styles::basic(s, plugin, true, visible);
s.push_str(title);
s.push_str("2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n");
s.push_str(&d);
s.push_str("/StrokeStyle : 0 R\n");
s.push_str(&ops);
s.push_str("0 1 0 0 0 Xy\n");
ai_ops::line_style(s, st);
s.push_str("0 XR\n1 0 Xd\n/Def ; \n (StrokeStyle) ,\n");
let quoted = ps::bytes(name.as_bytes());
let _ =
write!(s, "/GObjRef : {quoted} {} /PluginObject ; (Brush) ,\n{quoted} /UnicodeString (DisplayString) ,\n", ps::bytes(plugin.as_bytes()));
s.push_str(super::styles::END_BASIC);
Some(())
}
/// Does the reader read the brushed strokes of style text `text` with their brushes on them?
pub(super) fn brushed_style_reads(&mut self, text: &str) -> bool {
self.brush_set();
let section = super::styles::known_style("x", text);
let written = &self.brush_set.written;
let read = self.brush_set.read.get_or_insert_with(|| {
let brushes: Vec<(&str, &str, &str, Option<&Node>)> =
written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect();
crate::import::BrushesRead::new(&brushes)
});
read.style_reads(section.as_bytes(), "x")
}
/// The brushes' art in a non-printing block, then the brush manager's order and the brushes in
/// two more, when the document has brushes of its own or an object is written with a brush on
/// its stroke (a file without brushes opens with a new document's). Warns when a brush is left
/// out.
pub(in crate::ai_editing) fn brush_blocks(&mut self) -> (String, String) {
let stored = self.doc.unknown.contains_key(vectorcraft_brush::DOC_KEY);
if !stored && self.brush_set.live_written == 0 {
return (String::new(), String::new());
}
self.brush_set();
// The art is written now that the file holds it, after the layers' objects. A brush whose art
// doesn't fit is left out; a path written with it on its stroke opens as its drawn look.
let mut sections = String::new();
for mut b in std::mem::take(&mut self.brush_set.written) {
if let Some((name, art, to_section)) = b.section.take() {
let Some(text) = self.brush_art_section(&name, &art, to_section) else {
self.brush_set.left_out = true;
continue;
};
sections.push_str(&text);
}
self.brush_set.written.push(b);
}
if self.brush_set.left_out {
self.warn(BRUSHES);
}
// On a large canvas an empty panel is still written: the manager alone, with no art to scale.
let empty = self.brushes.as_ref().is_some_and(|l| l.is_empty());
if !self.brushes_live && !(stored && empty) {
return (String::new(), String::new());
}
let art = if sections.is_empty() { String::new() } else { format!("%AI5_Begin_NonPrinting\nNp\n{sections}%AI5_End_NonPrinting--\n") };
let manager = crate::import::BRUSH_MANAGER;
let order: String = self.brush_set.written.iter().map(|b| format!(" {}/ {}/", b.plugin, b.brush.name)).collect();
let mut objects = format!("%AI5_Begin_NonPrinting\nNp\n{}%AI5_End_NonPrinting--\n", plugin_object(manager, manager, &order));
if !self.brush_set.written.is_empty() {
objects.push_str("%AI5_Begin_NonPrinting\nNp\n");
for b in &self.brush_set.written {
objects.push_str(&plugin_object(b.plugin, &b.brush.name, &b.params));
}
objects.push_str("%AI5_End_NonPrinting--\n");
}
(art, objects)
}
}
+39 -15
View File
@@ -40,8 +40,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
}
/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]),
/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic
/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency
/// by id: paths and compound paths with points and groups, with live effects, linked to a graphic
/// style or (paths and compound paths) with a brush on a stroke, without Opacity & Mask Define
/// Knockout Shape (the reader takes an object's transparency
/// from its look, which doesn't carry it). A group's members are paths, compound paths or such
/// groups, none with effects or anything baking changes.
pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap<NodeId, Arc<Node>> {
@@ -76,7 +77,8 @@ fn bakes_members(n: &Node) -> bool {
/// Whether `n` may be written with an art style ([`styled_objects`]).
fn candidate(n: &Node) -> bool {
let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some();
let brushed = brushed(n) && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some() || brushed;
if !styled || n.knockout_shape {
return false;
}
@@ -88,6 +90,12 @@ fn candidate(n: &Node) -> bool {
}
}
/// Does `n` have a stroke with a brush that paints, one that its art style holds ([`Writer::style_text`]
/// leaves out what paints nothing)?
fn brushed(n: &Node) -> bool {
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none()))
}
/// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)?
fn has_points(path: &vectorcraft_geom::PathData) -> bool {
path.subpaths.iter().any(|sp| !sp.anchors.is_empty())
@@ -144,7 +152,7 @@ impl Styles {
}
/// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text.
fn known_style(name: &str, text: &str) -> String {
pub(super) fn known_style(name: &str, text: &str) -> String {
format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes()))
}
@@ -172,7 +180,7 @@ const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n";
/// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the
/// style's own filters), `/Visible` and the dictionary's start.
fn basic(s: &mut String, name: &str, effect: bool, visible: bool) {
pub(super) fn basic(s: &mut String, name: &str, effect: bool, visible: bool) {
let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible));
}
@@ -180,7 +188,7 @@ fn basic(s: &mut String, name: &str, effect: bool, visible: bool) {
const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n";
/// A basic filter's end.
const END_BASIC: &str = "; /Dict ;\n /Part ,\n";
pub(super) const END_BASIC: &str = "; /Dict ;\n /Part ,\n";
/// The blend filter of transparency `xy`.
fn blend_filter(s: &mut String, xy: Xy) {
@@ -228,7 +236,7 @@ fn flag(p: &Value, key: &str) -> Option<bool> {
impl Writer<'_> {
/// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string.
fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String {
pub(super) fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String {
let saved = std::mem::take(&mut self.body);
self.color(paint, stroke);
std::mem::replace(&mut self.body, saved)
@@ -267,9 +275,12 @@ impl Writer<'_> {
}
/// A stroke's basic filter: a color, its line style, and its width profile as the arrays
/// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for
/// other paint.
/// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`; a stroke with a
/// brush, the brush's filter ([`Self::brush_filter`]). `None` for other paint.
fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> {
if st.brush.is_some() {
return self.brush_filter(s, st, visible);
}
if !matches!(st.paint, Paint::Solid { .. }) || st.overprint {
return None;
}
@@ -652,17 +663,19 @@ impl Writer<'_> {
&& let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone())
&& object_reads(orig, &text)
{
self.styles.links_written += 1;
return Some(name);
}
if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) {
let brushed = brushed(orig);
if (orig.appearance.effects.is_empty() && !brushed) || !own_style_holds(&orig.appearance) {
return None;
}
let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_)));
let saved = (graded.then(|| self.gradient_mark()), self.warnings.len());
let path = drawn_outline(n, orig);
let rule = object_rule(orig);
let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t));
let text = self
.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule)))
.filter(|t| object_reads(orig, t) && (!brushed || self.brushed_style_reads(t)));
let Some(text) = text else {
if let Some(mark) = saved.0 {
self.gradient_rollback(mark);
@@ -693,7 +706,8 @@ impl Writer<'_> {
/// compound path's points painted with nothing, or with its gradient fill when its style has
/// one; a group with its members), its name and opacity mask; then `1 (name) XW`.
/// The look's group and the object itself are counted first ([`Self::take_objects`]): without
/// room for them, nothing is written.
/// room for them, nothing is written, and the object counts neither as written with its graphic
/// style nor as written with a brush on its stroke.
pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) {
let own = match &orig.kind {
NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()),
@@ -704,6 +718,12 @@ impl Writer<'_> {
if !self.take_objects(1 + own) {
return;
}
let linked = orig.extras().graphic_style.and_then(|id| self.styles.panel.get(&id)).is_some_and(|p| p == name);
if linked {
self.styles.links_written += 1;
} else if brushed(orig) {
self.brush_set.live_written += 1;
}
self.state(orig.locked, !orig.visible);
let xy = Xy::of(orig);
let was = self.in_look;
@@ -834,8 +854,12 @@ fn own_style_holds(ap: &Appearance) -> bool {
ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only)
}
/// Does the reader read graphic style `g` from style text `text` as it is?
/// Does the reader read graphic style `g` from style text `text` as it is? It leaves out a graphic
/// style with a brush on a stroke that paints.
fn panel_reads(g: &GraphicStyle, text: &str) -> bool {
if g.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) {
return false;
}
let section = known_style("x", text);
let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false };
// The opacity within the written number's precision.
@@ -918,7 +942,7 @@ fn normalized_params(e: &Effect) -> Value {
const NEAR: f64 = 6e-3;
/// JSON values equal but for numbers within the written numbers' precision.
fn near(a: &Value, b: &Value) -> bool {
pub(super) fn near(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) => (x - y).abs() <= NEAR,
+2 -2
View File
@@ -86,8 +86,8 @@ pub use ai::NameEncoding;
#[cfg(test)]
pub(crate) use ai::objects_read;
pub(crate) use ai::{
MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with,
symbol_art_member, warp_number,
BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept,
read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number,
};
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
+1
View File
@@ -34,6 +34,7 @@ use std::io::Read as _;
use super::lex::find;
use crate::ps;
pub(crate) use brush::{BrushesRead, MANAGER_NAME as BRUSH_MANAGER, brushes_kept, plugin_name as brush_plugin_name, read_brush_object};
pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
#[cfg(test)]
pub(crate) use envelope::MAX_CHECKED;
+124
View File
@@ -563,3 +563,127 @@ pub(super) fn without_frame(art: Node) -> Node {
}
out
}
/// The plug-in name the objects of brushes of kind `kind` have, for writers.
pub(crate) fn plugin_name(kind: &BrushKind) -> &'static str {
let tool = match kind {
BrushKind::Calligraphic(_) => Tool::Calligraphic,
BrushKind::Scatter(_) => Tool::Scatter,
BrushKind::Art(_) => Tool::Art,
BrushKind::Pattern(_) => Tool::Pattern,
BrushKind::Bristle(_) => Tool::Bristle,
};
TOOLS.iter().find(|(_, t)| *t == tool).map_or("", |(n, _)| *n)
}
/// The plug-in name of the brush manager's order, for writers.
pub(crate) const MANAGER_NAME: &str = MANAGER;
/// A brush object as the reader reads it, for writers that check a brush before they write it.
#[derive(Clone, Debug)]
pub(crate) struct BrushRead {
/// The brush the Brushes panel gets.
pub brush: Brush,
/// A path whose art style names the brush, at the brush's own settings, opens with the brush on
/// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file
/// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the
/// path, unflipped, at 100% in Tints.
pub live: bool,
}
/// The brush named `name` whose object has the plug-in name `plugin` and the parameters `params`,
/// with `art` as the art its parameters name (as the reader reads that art), as the reader reads
/// it: `None` when the reader doesn't make a brush of it.
pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Option<&Node>) -> Option<BrushRead> {
let draft = parse_tool_object(tool(plugin)?, name, params).ok()?;
let mut sections = HashMap::new();
if let (DraftKind::Scatter { art: section, .. } | DraftKind::Art { art: section, .. }, Some(a)) = (&draft.kind, art) {
sections.insert(section.clone(), a.clone());
}
let drafts = [draft];
let live = kept(&drafts, &sections).first().copied().unwrap_or(false)
&& match &drafts[0].kind {
DraftKind::Calligraphic(_) => true,
DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, &sections).contains(section),
_ => false,
};
let [draft] = drafts;
let (mut brushes, _) = finish_brushes(vec![draft], &sections, None);
Some(BrushRead { brush: brushes.pop()?, live })
}
/// Which of the brush objects `brushes` (plug-in name, name, parameters, the art each names as the
/// reader reads it) the reader makes into brushes when one file holds them all: not one it can't read,
/// nor one past the art the reader copies into one file's brushes ([`kept`]).
pub(crate) fn brushes_kept(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Vec<bool> {
let mut drafts = vec![];
let mut at = vec![];
let mut sections = HashMap::new();
for (i, (plugin, name, params, art)) in brushes.iter().enumerate() {
let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue };
if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) {
sections.insert(s.clone(), (*a).clone());
}
drafts.push(d);
at.push(i);
}
let mut out = vec![false; brushes.len()];
for (i, k) in at.into_iter().zip(kept(&drafts, &sections)) {
if let Some(slot) = out.get_mut(i) {
*slot = k;
}
}
out
}
/// Brush objects a writer writes, as the reader reads them together from one file, for checking
/// the art styles that name them ([`Self::style_reads`]).
pub(crate) struct BrushesRead {
drafts: Vec<BrushDraft>,
/// The brush a stroke that names a brush is drawn with, by name: the first one kept
/// ([`kept`]), as an index in [`Self::drafts`].
by_name: HashMap<String, usize>,
/// The art an Art brush draws as the file does ([`bending_art`]).
bending: HashSet<String>,
}
impl BrushesRead {
/// `brushes`: the brush objects written (plug-in name, name, parameters) and the art each names,
/// as the reader reads it.
pub(crate) fn new(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Self {
let mut drafts = vec![];
let mut sections = HashMap::new();
for (plugin, name, params, art) in brushes {
let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue };
if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) {
sections.insert(s.clone(), (*a).clone());
}
drafts.push(d);
}
let mut by_name = HashMap::new();
for (i, (d, k)) in drafts.iter().zip(kept(&drafts, &sections)).enumerate() {
if k {
by_name.entry(d.name.clone()).or_insert(i);
}
}
let bending = bending_art(&drafts, &sections);
Self { drafts, by_name, bending }
}
/// Does the reader read every stroke of art style `style` in `section` (the text between
/// `%AI9_BeginArtStyles` and its end) that names a brush with that brush on it, at the brush's
/// own settings? A style the reader doesn't read, or that names a brush no object gives, or an
/// Art brush in a style with effects, isn't read so.
pub(crate) fn style_reads(&self, section: &[u8], style: &str) -> bool {
use super::style::StyleItem;
let styles = super::style::art_styles(section);
let Some(st) = styles.get(style).filter(|st| st.plain) else { return false };
st.items.iter().all(|item| match item {
StyleItem::Stroke { brush: Some((name, settings)), .. } => {
let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false };
draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty())
}
_ => true,
})
}
}
+20 -3
View File
@@ -1237,8 +1237,11 @@ pub(crate) fn read_style_list(section: &[u8]) -> Vec<String> {
/// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`],
/// with a style's one gradient fill as `own_gradient`, the object's own gradient, as [`styled`] reads
/// it on objects other than type. `None` when the section doesn't define it, the reader doesn't read
/// it, or it paints with a pattern, a brush or a gradient that isn't the object's own.
/// it on objects other than type. A stroke that names a brush is read with that brush's name, as
/// [`styled`] reads it on a path or compound path when the Brushes panel keeps that brush at those
/// settings, which [`crate::import::ai::brush::BrushesRead::style_reads`] checks. `None` when the section
/// doesn't define it, the reader doesn't read it, or it paints with a pattern or a gradient that
/// isn't the object's own.
pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&Paint>) -> Option<StyleRead> {
use crate::import::ai::style::StyleItem;
let mut st = crate::import::ai::style::art_styles(section).remove(name)?;
@@ -1250,7 +1253,21 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
}
}
}
let appearance = appearance_from_art_style(&st)?;
// The brush names, by item; `appearance_from_art_style` reads a style's items in order.
let brushes: Vec<Option<String>> = st
.items
.iter_mut()
.map(|item| match item {
StyleItem::Stroke { brush, .. } => brush.take().map(|(name, _)| name),
_ => None,
})
.collect();
let mut appearance = appearance_from_art_style(&st)?;
for (item, brush) in appearance.items.iter_mut().zip(brushes) {
if let (AppearanceItem::Stroke(s), Some(brush)) = (item, brush) {
s.brush = Some(brush);
}
}
Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency })
}
+11 -1
View File
@@ -110,7 +110,17 @@ private editing data. VectorCraft:
Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag and Gaussian Blur too). Styles with gradient or pattern fills,
with fills, strokes or effects the reader doesn't read back, with a name another style has, with names over 1,024
bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's
styles, with a warning. No art style is written on a large canvas.
styles, with a warning. No art style is written on a large canvas. The Brushes panel's Calligraphic brushes, and its
Scatter and Art brushes with their art, are written in its order: Scatter and Art brushes in Tints or keeping their
art's colors, Art brushes that stretch their art along the path from left to right at 100% unflipped without
overlap, and art of paths and compound paths with points and groups with members, with one solid fill and one
plain stroke each. Other brushes,
Pattern and Bristle brushes among them, and brushes past the number or the art VectorCraft's reader reads are left
out with a warning; no brush is written on a large canvas. A path or compound path with a Calligraphic brush, or
with an Art brush in Tints whose art bends along the path, keeps the brush on its stroke; other brushed strokes are
written as their brush's art. A document that uses a new document's brushes writes none until a path keeps one on
its stroke; then the brushes are written, and the file opens without the new document's Pattern and Bristle brushes
and its Art brush with other options.
Point type, area type (with its rows, columns,
gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for
each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift,