mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Import: live blends are checked in bounded time and keep no stray art
Review follow-ups for the live blend import: - The check of a blend against its drawn steps matched every drawn step against every laid one and measured centres inside the loop, so a crafted blend (64 keys, 1,000 steps between each) could stall the import. Centres and fills are now measured once, and each drawn step finds its neighbours through a grid of cells the tolerance wide. - A blend lays out at most 5,000 objects to be made live, and all the blends of one import at most 50,000 (each laid out twice, once per orientation); past either, the drawn steps stay, with the note. The live steps take the place of drawn steps already counted against the reader's object limit. - A fill the check can't compare (a gradient, a pattern) or a missing one no longer passes: the fills must both be none or solid colours within the tolerance. - When what follows the blend's own art isn't a group of drawn steps, that art (its keys and spine) goes too, with the note, instead of staying drawn. - The spine test can no longer pass without asserting: a spine written backwards keeps the keys' order (live), and a curved spine is read exactly while its straight-drawn steps stay drawn. New tests for steps with a gradient, for the blend's art when its steps aren't a group, and for the budget; import_fuzz gains a mutated-blend property and a large-blend test (998 steps live, 1,500 kept drawn, both quickly).
This commit is contained in:
@@ -4265,3 +4265,77 @@ fn a_long_thin_shape_with_a_shadow_renders_and_exports() {
|
||||
let doc = vectorcraft_engine::cmd::fileio::load("long.af", &bytes).unwrap().doc;
|
||||
survive("long shape", || Some(doc)).unwrap();
|
||||
}
|
||||
|
||||
/// A blend in `.ai` editing data, written by hand: two keys (10 pt squares, red on the left, blue on
|
||||
/// the right) on a straight spine, `steps` steps drawn between them as the blend lays them out, and
|
||||
/// the record (`DCBA`, version 1, two keys `A` and `B`, see `vectorcraft_eps`' AI blend reader).
|
||||
fn blend_text(steps: usize) -> String {
|
||||
use std::fmt::Write;
|
||||
let square = |x: f64, hidden: bool, t: f64| {
|
||||
let p = if hidden { "%_" } else { "" };
|
||||
format!("{p}0 0 0 0 {} 0 {} Xa\n{p}{x} 40 m\n{p}{x} 50 L\n{p}{} 50 L\n{p}{} 40 L\n{p}f\n", 1.0 - t, t, x + 10.0, x + 10.0)
|
||||
};
|
||||
let part = |kind: &str, id: Option<&str>| {
|
||||
let id = id.map(|i| format!("%_({i}) /String (ShapeBlendPathID) ,\n")).unwrap_or_default();
|
||||
format!("%_/ArtDictionary :\n{id}%_({kind}) /String (ShapeBlendPathType) ,\n%_;\n")
|
||||
};
|
||||
let span = 10.0 * (steps as f64 + 1.0);
|
||||
let mut s = String::from("%_0 Ae\n%_u\n");
|
||||
s += &square(0.0, true, 0.0);
|
||||
s += &part("K", Some("A"));
|
||||
s += &square(span, true, 1.0);
|
||||
s += &part("K", Some("B"));
|
||||
let _ = write!(s, "%_5 45 m\n%_{} 45 L\n%_N\n", span + 5.0);
|
||||
s += &part("S", None);
|
||||
s += "%_U\n%_9 () XW\nu\n";
|
||||
for i in 0..steps + 2 {
|
||||
let t = i as f64 / (steps as f64 + 1.0);
|
||||
s += &square(t * span, false, t);
|
||||
}
|
||||
s += "U\n9 () XW\n0 Ae\n";
|
||||
let mut words: Vec<u32> = vec![u32::from_le_bytes(*b"DCBA"), 1, 0, 0, 0, 0, 0, 0, 0, 0, 2];
|
||||
for id in ['A', 'B'] {
|
||||
words.extend([id as u32, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
let hex: String = words.iter().flat_map(|w| w.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
|
||||
let _ = write!(s, "(Adobe Path Blends) 1 0 {} XP\n%{hex}\n", hex.len() / 2);
|
||||
s
|
||||
}
|
||||
|
||||
/// `.ai` editing data holding `layer_art` on one layer, compressed as the private data is.
|
||||
fn ai_private(layer_art: &str) -> Vec<u8> {
|
||||
use vectorcraft_testkit::ai;
|
||||
let data = ai::editing_data(4000.0, 200.0, &ai::layer("L", layer_art));
|
||||
[b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat()
|
||||
}
|
||||
|
||||
/// A blend of many steps doesn't stall the import: one within the limits (a thousand steps between
|
||||
/// two keys) is checked and made live quickly, one past them keeps its drawn steps without being
|
||||
/// laid out.
|
||||
#[test]
|
||||
fn large_ai_blends_import_in_bounded_time() {
|
||||
for (steps, live) in [(3, true), (998, true), (1_500, false)] {
|
||||
let private = ai_private(&blend_text(steps));
|
||||
let started = std::time::Instant::now();
|
||||
let (doc, notes, read) = vectorcraft_eps::layered_ai(&private, Document::new(4000.0, 200.0), vec![], false, true, 1.0);
|
||||
assert!(read, "{steps} steps: read from the layers ({notes:?})");
|
||||
let took = started.elapsed();
|
||||
assert!(took < std::time::Duration::from_secs(30), "{steps} steps took {took:?}");
|
||||
let mut blends = 0;
|
||||
doc.walk(|n| blends += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Blend { .. })));
|
||||
assert_eq!(blends == 1, live, "{steps} steps: live {live}");
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// A `.ai` blend whose record, parts or drawn steps are damaged or hostile: read live, as its
|
||||
/// drawn steps, or not at all, never panicking.
|
||||
#[test]
|
||||
fn ai_blend_never_panics(steps in 1usize..6, cut in 0usize..2_500, edits in prop::collection::vec((0usize..2_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '%', '_', 'K', 'S', 'A', 'B', 'u', 'U', 'F', 'X'])), 0..12)) {
|
||||
let text = mutate_text(&blend_text(steps), cut, &edits);
|
||||
let private = ai_private(&text);
|
||||
survive("mutated .ai blend", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ pub(super) const MAX_RECORD: usize = 4096;
|
||||
const MAX_KEYS: usize = 64;
|
||||
/// The most steps between two keys (Blend Options' limit).
|
||||
const MAX_STEPS: usize = 1000;
|
||||
/// The most objects (keys and steps) one blend may lay out to be read live; more keep their
|
||||
/// drawn steps.
|
||||
pub(super) const MAX_BLEND_OBJECTS: usize = 5_000;
|
||||
/// How far (pt) a step laid out here may be from where the file draws it.
|
||||
const PLACE_TOLERANCE: f64 = 0.5;
|
||||
/// How far a step's colour (each channel, 0 to 1) may be from the drawn one.
|
||||
@@ -79,7 +82,12 @@ pub(super) fn record_ids(bytes: &[u8]) -> Option<Vec<String>> {
|
||||
/// The blend `edit` (its key objects and spine, as their dictionaries name them in `roles`) and
|
||||
/// `drawn` (its steps as the file draws them) make, with the keys of the record `ids`: one live
|
||||
/// blend, or `None` when they don't agree or it doesn't come out as drawn.
|
||||
pub(super) fn live_blend(edit: &Node, drawn: &Node, ids: &[String], roles: &Roles) -> Option<NodeKind> {
|
||||
///
|
||||
/// `budget`: how many more objects blends may lay out in this import; a blend that would lay out
|
||||
/// more isn't made live (each blend is laid out twice, once per orientation, and checked against
|
||||
/// its drawn steps, so a file of huge blends mustn't stall the import). What it lays out is taken
|
||||
/// from it.
|
||||
pub(super) fn live_blend(edit: &Node, drawn: &Node, ids: &[String], roles: &Roles, budget: &mut usize) -> Option<NodeKind> {
|
||||
let parts = edit.children()?;
|
||||
let role = |n: &Arc<Node>| roles.get(&n.id);
|
||||
let keys: Vec<Arc<Node>> = parts.iter().filter(|n| role(n).is_some_and(|(t, _)| t == "K")).cloned().collect();
|
||||
@@ -101,9 +109,17 @@ pub(super) fn live_blend(edit: &Node, drawn: &Node, ids: &[String], roles: &Role
|
||||
// The steps between two keys: the drawn objects that aren't keys, shared out.
|
||||
let drawn: Vec<&Arc<Node>> = drawn.children()?.iter().collect();
|
||||
let between = (drawn.len().checked_sub(keys.len())?).checked_div(keys.len() - 1)?;
|
||||
if between == 0 || between > MAX_STEPS || drawn.len() != keys.len() + between * (keys.len() - 1) {
|
||||
if between == 0 || between > MAX_STEPS || drawn.len() != keys.len() + between * (keys.len() - 1) || drawn.len() > MAX_BLEND_OBJECTS {
|
||||
return None;
|
||||
}
|
||||
// Both orientations are laid out.
|
||||
let cost = drawn.len().saturating_mul(2);
|
||||
if cost > *budget {
|
||||
return None;
|
||||
}
|
||||
*budget -= cost;
|
||||
// The drawn steps' centres and paints, once.
|
||||
let drawn: Vec<(Point, Fill)> = drawn.iter().map(|d| Some((centre(d)?, fill(d)))).collect::<Option<_>>()?;
|
||||
let keys: Vec<Arc<Node>> = keys
|
||||
.into_iter()
|
||||
.map(|k| {
|
||||
@@ -149,36 +165,68 @@ fn centre(n: &Node) -> Option<Point> {
|
||||
n.geometric_bounds().map(|b| b.center()).filter(|p| p.x.is_finite() && p.y.is_finite())
|
||||
}
|
||||
|
||||
/// An object's first solid fill colour, as RGB.
|
||||
fn fill(n: &Node) -> Option<[f32; 3]> {
|
||||
n.appearance.items.iter().find_map(|i| match i {
|
||||
AppearanceItem::Fill(f) => match &f.paint {
|
||||
Paint::Solid { color, .. } => Some(Color::to_rgb(color)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
/// An object's fill, as far as the check compares it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Fill {
|
||||
/// No fill.
|
||||
None,
|
||||
/// A solid colour, as RGB.
|
||||
Solid([f32; 3]),
|
||||
/// A fill this can't compare (a gradient, a pattern): the blend isn't made live.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// How far the objects `laid` are from `drawn` (the farthest centre, each drawn object matched to
|
||||
/// the nearest laid one), when they are as many and each matches in place and colour; else `None`.
|
||||
fn how_far(laid: &[Node], drawn: &[&Arc<Node>]) -> Option<f64> {
|
||||
/// An object's first fill.
|
||||
fn fill(n: &Node) -> Fill {
|
||||
n.appearance
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
AppearanceItem::Fill(f) => Some(match &f.paint {
|
||||
Paint::None => Fill::None,
|
||||
Paint::Solid { color, .. } => Fill::Solid(Color::to_rgb(color)),
|
||||
_ => Fill::Other,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(Fill::None)
|
||||
}
|
||||
|
||||
/// Do two fills match: both none, or solid colours within the tolerance?
|
||||
fn same_fill(a: Fill, b: Fill) -> bool {
|
||||
match (a, b) {
|
||||
(Fill::None, Fill::None) => true,
|
||||
(Fill::Solid(a), Fill::Solid(b)) => a.iter().zip(b).all(|(x, y)| (x - y).abs() <= COLOR_TOLERANCE),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the objects `laid` are from the drawn ones (`drawn`: their centres and fills): the
|
||||
/// farthest, each drawn object matched to a laid one within the tolerance (through a grid of
|
||||
/// cells the tolerance wide, so each finds its neighbours without looking at them all), when they
|
||||
/// are as many and each matches in place and fill; else `None`.
|
||||
fn how_far(laid: &[Node], drawn: &[(Point, Fill)]) -> Option<f64> {
|
||||
if laid.len() != drawn.len() {
|
||||
return None;
|
||||
}
|
||||
let cell = |p: Point| ((p.x / PLACE_TOLERANCE).floor() as i64, (p.y / PLACE_TOLERANCE).floor() as i64);
|
||||
let laid: Vec<(Point, Fill)> = laid.iter().map(|l| Some((centre(l)?, fill(l)))).collect::<Option<_>>()?;
|
||||
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::with_capacity(laid.len());
|
||||
for (i, (p, _)) in laid.iter().enumerate() {
|
||||
grid.entry(cell(*p)).or_default().push(i);
|
||||
}
|
||||
let mut worst: f64 = 0.0;
|
||||
for d in drawn {
|
||||
let c = centre(d)?;
|
||||
let (near, off) = laid.iter().filter_map(|l| Some((l, (centre(l)? - c).hypot()))).min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
if off > PLACE_TOLERANCE {
|
||||
return None;
|
||||
}
|
||||
if let (Some(a), Some(b)) = (fill(near), fill(d))
|
||||
&& a.iter().zip(b).any(|(x, y)| (x - y).abs() > COLOR_TOLERANCE)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
worst = worst.max(off);
|
||||
for &(c, f) in drawn {
|
||||
let (cx, cy) = cell(c);
|
||||
let near = (-1..=1)
|
||||
.flat_map(|dx| (-1..=1).map(move |dy| (cx + dx, cy + dy)))
|
||||
.filter_map(|k| grid.get(&k))
|
||||
.flatten()
|
||||
.filter_map(|&i| laid.get(i))
|
||||
.filter(|(p, lf)| (*p - c).hypot() <= PLACE_TOLERANCE && same_fill(*lf, f))
|
||||
.map(|(p, _)| (*p - c).hypot())
|
||||
.min_by(f64::total_cmp)?;
|
||||
worst = worst.max(near);
|
||||
}
|
||||
Some(worst)
|
||||
}
|
||||
@@ -213,4 +261,15 @@ mod tests {
|
||||
assert_eq!(hex_bytes(b"4G"), None);
|
||||
assert_eq!(hex_bytes(b"4a4B"), Some(vec![0x4a, 0x4b]));
|
||||
}
|
||||
|
||||
/// A blend that would lay out more than the import's budget has left isn't made live, and
|
||||
/// takes nothing from it.
|
||||
#[test]
|
||||
fn a_blend_past_the_budget_isnt_made_live() {
|
||||
let edit = Node::new(NodeId(1), NodeKind::Group { children: vec![], clip: false });
|
||||
let drawn = Node::new(NodeId(2), NodeKind::Group { children: vec![], clip: false });
|
||||
let mut budget = 1;
|
||||
assert!(live_blend(&edit, &drawn, &["A".into(), "B".into()], &Roles::new(), &mut budget).is_none());
|
||||
assert_eq!(budget, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ const MAX_DEPTH: usize = 256;
|
||||
const MAX_STACK: usize = 1 << 20;
|
||||
/// Most objects read.
|
||||
const MAX_NODES: usize = 4_000_000;
|
||||
/// The most objects all the blends of one import may lay out to be checked (each twice) and made
|
||||
/// live; past it, blends keep their drawn steps.
|
||||
const BLEND_BUDGET: usize = 50_000;
|
||||
/// Most points in one path.
|
||||
const MAX_POINTS: usize = 4_000_000;
|
||||
/// Largest image read (pixels).
|
||||
@@ -509,6 +512,8 @@ struct Reader<'a> {
|
||||
blend_roles: super::blend::Roles,
|
||||
/// A blend's record being read from the `%` lines after its `XP`: its length and hex digits.
|
||||
blend_record: Option<(usize, Vec<u8>)>,
|
||||
/// How many more objects blends may lay out in this import ([`super::blend::live_blend`]).
|
||||
blend_budget: usize,
|
||||
/// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read.
|
||||
group_ended: bool,
|
||||
/// A layer's own state (`Xy` after its art, before `LB`), until something says the art moves on.
|
||||
@@ -673,6 +678,7 @@ impl<'a> Reader<'a> {
|
||||
hidden_ids: HashSet::new(),
|
||||
blend_roles: HashMap::new(),
|
||||
blend_record: None,
|
||||
blend_budget: BLEND_BUDGET,
|
||||
clip_fills: HashSet::new(),
|
||||
looks: HashSet::new(),
|
||||
compound_groups: HashSet::new(),
|
||||
@@ -1685,15 +1691,25 @@ impl<'a> Reader<'a> {
|
||||
let (Some(at), Some(drawn)) = (n.checked_sub(2), n.checked_sub(1)) else { return };
|
||||
let part_of_blend = |node: &Node| node.children().is_some_and(|c| c.iter().any(|k| roles.contains_key(&k.id)));
|
||||
let (Some(edit), Some(steps)) = (f.children.get(at), f.children.get(drawn)) else { return };
|
||||
if !part_of_blend(edit) || !matches!(steps.kind, NodeKind::Group { clip: false, .. }) {
|
||||
if !part_of_blend(edit) {
|
||||
return;
|
||||
}
|
||||
if !matches!(steps.kind, NodeKind::Group { clip: false, .. }) {
|
||||
// The steps aren't a group of drawn steps: the blend's own art goes all the same, so
|
||||
// its keys and spine aren't drawn over what the file draws.
|
||||
f.children.remove(at);
|
||||
self.warn(super::blend::DRAWN);
|
||||
return;
|
||||
}
|
||||
let live = ids.and_then(|ids| super::blend::live_blend(edit, steps, &ids, roles));
|
||||
let mut budget = self.blend_budget;
|
||||
let live = ids.and_then(|ids| super::blend::live_blend(edit, steps, &ids, roles, &mut budget));
|
||||
self.blend_budget = budget;
|
||||
let Some(mut steps) = f.children.pop() else { return };
|
||||
let Some(_edit) = f.children.pop() else { return };
|
||||
match live {
|
||||
Some(kind) => {
|
||||
// The steps the blend lays out count as the objects they draw, as its drawn steps
|
||||
// did (they were counted as read).
|
||||
let mut blend = Node::new(self.doc.alloc_id(), kind);
|
||||
(blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked);
|
||||
(blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout);
|
||||
|
||||
@@ -1647,41 +1647,67 @@ fn a_blend_comes_in_as_a_live_blend() {
|
||||
assert!(!s.warnings.iter().any(|w| w == super::blend::DRAWN), "{:?}", s.warnings);
|
||||
}
|
||||
|
||||
/// A curved spine (a line, a curve and a line) keeps its points exactly; written from the right
|
||||
/// key to the left or the other way, the keys stay in their order on it.
|
||||
/// A spine written from the right key to the left one keeps the keys in their order on it (the
|
||||
/// red key first), sitting on its last and first anchors.
|
||||
#[test]
|
||||
fn a_blends_spine_keeps_its_points_and_its_keys_order() {
|
||||
for spine in ["%_110 50 m\n%_95 45 l\n%_80 20 40 20 25 45 c\n%_10 50 L\n", "%_10 50 m\n%_25 45 l\n%_40 20 80 20 95 45 c\n%_110 50 L\n"] {
|
||||
// Drawn steps on a curve lie off the straight line: this blend comes out as drawn only
|
||||
// where its steps follow the spine, so the spine and the key order are what's checked.
|
||||
let (blend, _) = read_blend(&blend_file(spine, [Some("A"), Some("B")], &blend_record(&['A', 'B'])));
|
||||
let NodeKind::Blend { children, spec } = &blend.kind else {
|
||||
// Steps laid along this curve aren't where the straight drawing has them: the drawn
|
||||
// steps stay, which is right too; the spine's own reading is checked below.
|
||||
continue;
|
||||
};
|
||||
let red = |n: &Node| matches!(fill(n), Paint::Solid { color, .. } if color.to_rgb()[0] > 0.9);
|
||||
assert!(red(&children[0]), "the first key is the red one");
|
||||
let spine = spec.spine.as_ref().unwrap();
|
||||
assert_eq!(spine.subpaths[0].anchors.len(), 4);
|
||||
}
|
||||
// The spine's points as read, through the blend's own art.
|
||||
fn a_blends_spine_written_backwards_keeps_its_keys_order() {
|
||||
let (blend, warnings) = read_blend(&blend_file("%_110 50 m\n%_10 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B'])));
|
||||
let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a live blend: {:?} {warnings:?}", blend.kind) };
|
||||
assert!(matches!(fill(&children[0]), Paint::Solid { color, .. } if color.to_rgb()[0] > 0.9), "the first key is the red one");
|
||||
assert_eq!(spec.key_anchors, vec![1, 0], "the red key on the spine's end, the blue one on its start");
|
||||
}
|
||||
|
||||
/// A curved spine (a line, a curve and a line) is read with its points exactly; a blend whose drawn
|
||||
/// steps don't follow it (they are drawn on a straight line here) keeps its drawn steps.
|
||||
#[test]
|
||||
fn a_curved_spine_keeps_its_points() {
|
||||
let spine = "%_110 50 m\n%_95 45 l\n%_80 20 40 20 25 45 c\n%_10 50 L\n";
|
||||
let s = read_ok(&layer(&format!("%_u\n{spine}%_N\n%_U\n")));
|
||||
let path = s.doc.layers[0].children().unwrap().iter().find_map(|n| {
|
||||
let mut found = None;
|
||||
let mut found = None;
|
||||
for n in art(&s.doc) {
|
||||
n.walk(&mut |c| {
|
||||
if let NodeKind::Path { path, .. } = &c.kind {
|
||||
found = Some(path.clone());
|
||||
}
|
||||
});
|
||||
found
|
||||
});
|
||||
if let Some(path) = path {
|
||||
let a = &path.subpaths[0].anchors;
|
||||
assert_eq!(a.len(), 4);
|
||||
assert!((a[1].h_out.x - 80.0).abs() < 1e-9 && (a[2].h_in.x - 40.0).abs() < 1e-9 && (a[2].p.x - 25.0).abs() < 1e-9, "{a:?}");
|
||||
}
|
||||
let path = found.expect("the spine is read");
|
||||
let a = &path.subpaths[0].anchors;
|
||||
assert_eq!(a.len(), 4);
|
||||
assert!((a[1].h_out.x - 80.0).abs() < 1e-9 && (a[2].h_in.x - 40.0).abs() < 1e-9 && (a[2].p.x - 25.0).abs() < 1e-9, "{a:?}");
|
||||
let (blend, warnings) = read_blend(&blend_file(spine, [Some("A"), Some("B")], &blend_record(&['A', 'B'])));
|
||||
assert!(!matches!(blend.kind, NodeKind::Blend { .. }), "steps drawn off the spine aren't made live");
|
||||
assert_eq!(blend.children().map(|c| c.len()), Some(5));
|
||||
assert!(warnings.iter().any(|w| w == super::blend::DRAWN));
|
||||
}
|
||||
|
||||
/// Steps with a gradient fill can't be compared: the blend keeps its drawn steps.
|
||||
#[test]
|
||||
fn a_blend_whose_steps_it_cant_compare_keeps_its_drawn_steps() {
|
||||
let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B']));
|
||||
// The drawn squares' fill made a gradient's.
|
||||
let gradient = "%AI5_BeginGradient: (g)\n(g) 0 2 Bd\n[\n1 0 0 0 1 %_Bs\n0 0 0 1 0 %_Bs\nBD\n%AI5_EndGradient\n";
|
||||
let drawn_from = body.find("\nu\n").expect("the drawn group") + 1;
|
||||
let (head, tail) = body.split_at(drawn_from);
|
||||
let tail = tail.replacen("Xa\n", "Xa\n0 1 0 0 1 0 0 1 0 0 Bb\n(g) 0 0 0 1 1 0 0 1 0 0 Bg\n", 5).replace("\nf\n", "\nf\n0 BB\n");
|
||||
let (blend, _) = read_blend(&format!("{gradient}{head}{tail}"));
|
||||
assert!(!matches!(blend.kind, NodeKind::Blend { .. }), "{:?}", blend.kind);
|
||||
}
|
||||
|
||||
/// The blend's own art (its keys and spine) isn't left drawn when what follows it isn't a group of
|
||||
/// drawn steps.
|
||||
#[test]
|
||||
fn a_blends_own_art_goes_when_its_steps_arent_a_group() {
|
||||
let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B']));
|
||||
// The drawn steps as one square, not a group.
|
||||
let at = body.find("\nu\n").expect("the drawn group") + 1;
|
||||
let end = body.find("U\n9 () XW\n0 Ae\n").expect("its end");
|
||||
let body = format!("{}{}{}", &body[..at], square(0.0, 40.0, 20.0, [1.0, 0.0, 0.0], false), &body[end + 2..]);
|
||||
let s = read_ok(&layer(&body));
|
||||
let objects = art(&s.doc);
|
||||
assert_eq!(objects.len(), 1, "the drawn square alone: {objects:?}");
|
||||
assert!(matches!(objects[0].kind, NodeKind::Path { .. }));
|
||||
assert!(s.warnings.iter().any(|w| w == super::blend::DRAWN));
|
||||
}
|
||||
|
||||
/// A record that isn't one, a key with no id, or ids the record doesn't list: the drawn steps
|
||||
|
||||
Reference in New Issue
Block a user