diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 8c4e95f4e6..b46b962ce4 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1038,7 +1038,7 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { /// The documents [`the_writer_counts_objects_as_the_reader_reads_them`] writes: groups, compound /// paths, guides, sublayers, clipping groups and layers, painted clipping paths, opacity masks, /// gradients, pattern definitions and tiles, images, type, symbol definitions and instances, -/// objects with art styles and objects bound to variables. +/// objects with art styles, objects bound to variables and live envelopes. fn counted() -> Vec<(&'static str, Document)> { vec![ ("sample", sample()), @@ -1053,6 +1053,7 @@ fn counted() -> Vec<(&'static str, Document)> { ("painted clips", painted_clips()), ("blend", blend_doc()), ("brushes", brushes_document()), + ("envelopes", envelopes_document()), ] } @@ -9055,3 +9056,446 @@ fn spread_gradients_past_the_stops_the_reader_reads_stay_gradients() { let Some(Paint::Gradient(swatch)) = back.swatch("Bands").map(|s| &s.paint) else { panic!("the swatch is lost") }; assert!((200..=crate::import::MAX_GRADIENT_STOPS).contains(&swatch.gradient.stops.len()), "{} stops", swatch.gradient.stops.len()); } + +/// An envelope named `Bent` of `kind` around a red rectangle (40, 20)-(100, 80) and a blue one +/// (100, 20)-(160, 80), with Fidelity `fidelity`, Edit Contents `editing` and `options`. +fn envelope_node( + doc: &mut Document, + kind: vectorcraft_doc::live::EnvelopeKind, + fidelity: f64, + editing: bool, + options: vectorcraft_doc::live::EnvelopeOptions, +) -> Node { + let content = vec![ + Arc::new(square(doc, Rect::new(40.0, 20.0, 100.0, 80.0), Color::rgb(1.0, 0.0, 0.0))), + Arc::new(square(doc, Rect::new(100.0, 20.0, 160.0, 80.0), Color::rgb(0.0, 0.0, 1.0))), + ]; + let kind = NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing, options, frame: Box::default() }; + named(Node::new(doc.alloc_id(), kind), "Bent") +} + +/// A 200 × 100 document of envelope `kind` ([`envelope_node`]) alone. +fn envelope_doc( + kind: vectorcraft_doc::live::EnvelopeKind, + fidelity: f64, + editing: bool, + options: vectorcraft_doc::live::EnvelopeOptions, +) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let envelope = envelope_node(&mut doc, kind, fidelity, editing, options); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + doc +} + +/// A grid of `rows`×`cols` patches over (30, 10)-(170, 90), row by row from the top left, with its +/// sixth point moved. +fn envelope_points(rows: u32, cols: u32) -> Vec { + let mut points: Vec = (0..=rows) + .flat_map(|r| { + (0..=cols).map(move |c| kurbo::Point::new(30.0 + 140.0 * f64::from(c) / f64::from(cols), 10.0 + 80.0 * f64::from(r) / f64::from(rows))) + }) + .collect(); + points[5] += kurbo::Vec2::new(5.0, -8.0); + points +} + +/// The envelopes written live: warps (vertical, with distortions, and twisted), meshes (with +/// handles of their own, one handle uneven, and with the smooth ones), top objects (a curved top +/// side, and two segments to the top and bottom sides), with Edit Contents off and on. +fn live_envelope_kinds() -> Vec<(&'static str, vectorcraft_doc::live::EnvelopeKind)> { + use vectorcraft_doc::live::{EnvelopeKind, H_RIGHT, WARP_NET, smooth_handles}; + let warp = |style: &str, bend: f64, h: f64, v: f64, horizontal: bool| EnvelopeKind::Warp { + style: style.into(), + bend, + h, + v, + horizontal, + version: WARP_NET, + }; + let points = envelope_points(2, 3); + let mut handles = smooth_handles(2, 3, &points); + handles[5][H_RIGHT] += kurbo::Vec2::new(4.0, 6.0); + let mut curved = kurbo::BezPath::new(); + curved.move_to((30.0, 10.0)); + curved.curve_to((70.0, -5.0), (130.0, -5.0), (170.0, 10.0)); + curved.line_to((170.0, 90.0)); + curved.line_to((30.0, 90.0)); + curved.close_path(); + let mut six = kurbo::BezPath::new(); + six.move_to((30.0, 10.0)); + for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (100.0, 95.0), (30.0, 90.0)] { + six.line_to(p); + } + six.close_path(); + vec![ + ("arc", warp("arc", 50.0, -20.0, 30.0, false)), + ("twist", warp("twist", -35.0, 0.0, 0.0, true)), + ("mesh", EnvelopeKind::Mesh { rows: 2, cols: 3, points: points.clone(), handles }), + ("smooth mesh", EnvelopeKind::Mesh { rows: 2, cols: 3, points, handles: vec![] }), + ("curved top object", EnvelopeKind::TopObject { path: PathData::from_bezpath(&curved), corners: None }), + ("six-anchor top object", EnvelopeKind::TopObject { path: PathData::from_bezpath(&six), corners: None }), + ] +} + +/// `doc` written and read back from its editing data: the top object on its first layer, the +/// writer's warnings and the reader's notes. +fn envelope_back(doc: &Document) -> (Node, Vec, Vec) { + let (p, warnings) = private_data(doc, &opts()).unwrap(); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let a = back.layers[0].children().unwrap(); + assert_eq!(a.len(), 1, "{a:#?}"); + ((*a[0]).clone(), warnings, notes) +} + +/// Does reopened envelope kind `got` have the shape of `want`: a warp's style, bend, distortions +/// and orientation; a mesh's grid and the handles that draw (those inside the grid); the patch a top +/// object's outline makes? +fn same_envelope_kind(want: &vectorcraft_doc::live::EnvelopeKind, got: &vectorcraft_doc::live::EnvelopeKind) -> bool { + use vectorcraft_doc::live::{Coons, EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, smooth_handles}; + let near = |a: kurbo::Point, b: kurbo::Point| a.distance(b) < 1e-3; + match (want, got) { + ( + EnvelopeKind::Warp { style, bend, h, v, horizontal, version }, + EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal: hz, version: ver }, + ) => style == s && horizontal == hz && version == ver && [(bend, b), (h, dh), (v, dv)].iter().all(|(x, y)| (*x - *y).abs() < 1e-9), + (EnvelopeKind::Mesh { rows, cols, points, handles }, EnvelopeKind::Mesh { rows: r, cols: c, points: p, handles: hs }) => { + let own = if handles.is_empty() { smooth_handles(*rows, *cols, points) } else { handles.clone() }; + let inside = |i: usize, slot: usize| { + let (row, col) = (i / (*cols as usize + 1), i % (*cols as usize + 1)); + match slot { + s if s == H_LEFT => col > 0, + s if s == H_RIGHT => col < *cols as usize, + s if s == H_UP => row > 0, + s if s == H_DOWN => row < *rows as usize, + _ => false, + } + }; + (rows, cols) == (r, c) + && points.len() == p.len() + && points.iter().zip(p).all(|(a, b)| near(*a, *b)) + && own.iter().zip(hs).enumerate().all(|(i, (a, b))| (0..4).all(|s| !inside(i, s) || near(a[s].to_point(), b[s].to_point()))) + } + (EnvelopeKind::TopObject { path, corners }, EnvelopeKind::TopObject { path: p, corners: c }) => { + let (Some(a), Some(b)) = (Coons::from_top_object(path, *corners), Coons::from_top_object(p, *c)) else { return false }; + (0..=8).all(|j| { + (0..=8).all(|i| a.eval(f64::from(i) / 8.0, f64::from(j) / 8.0).distance(b.eval(f64::from(i) / 8.0, f64::from(j) / 8.0)) < 1e-2) + }) + } + _ => false, + } +} + +/// A PDF-compatible `.ai`'s editing data keeps a warp, a mesh and a top object envelope live, with +/// Edit Contents off and on: VectorCraft's reader opens each as the same envelope, its contents, +/// Fidelity, Edit Contents, Envelope Options and shape as saved, and its name, the drawn art saved +/// with it drawing as the envelope does. +#[test] +fn envelopes_are_read_back_live() { + use vectorcraft_doc::live::EnvelopeOptions; + for (name, kind) in live_envelope_kinds() { + for editing in [false, true] { + let doc = envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW); + let (back, warnings, notes) = envelope_back(&doc); + let what = format!("{name}, editing {editing}"); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}"); + assert!(!notes.iter().any(|w| w == crate::import::ENVELOPE_DRAWN), "{what}: {notes:?}"); + let NodeKind::Envelope { content, kind: got, fidelity, editing: e, options, frame } = &back.kind else { panic!("{what}: {back:#?}") }; + assert_eq!((*fidelity, *e, *options, **frame), (50.0, editing, EnvelopeOptions::NEW, Affine::IDENTITY), "{what}"); + assert!(same_envelope_kind(&kind, got), "{what}: {got:?}"); + assert_eq!(back.name.as_deref(), Some("Bent"), "{what}"); + let colors: Vec> = content.iter().map(|c| c.appearance.fill_paint().color()).collect(); + assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}"); + // Saved again as it opened (a top object now with the corners the file gives it), it + // opens as the same envelope again. + let mut again = Document::new(200.0, 100.0); + let layer = again.layers[0].id; + again.insert(Some(layer), usize::MAX, back.clone()).unwrap(); + let (twice, warnings, notes) = envelope_back(&again); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}, saved again: {warnings:?}"); + let NodeKind::Envelope { kind: twice, .. } = &twice.kind else { panic!("{what}, saved again: {twice:#?} {notes:?}") }; + assert!(same_envelope_kind(&kind, twice), "{what}, saved again: {twice:?}"); + } + } +} + +/// An envelope that is a symbol's art is written live in the symbol's definition, and the symbol +/// opens with it as a live envelope. +#[test] +fn an_envelope_in_a_symbol_is_read_back_live() { + use vectorcraft_doc::live::EnvelopeOptions; + let kinds = live_envelope_kinds(); + for (name, kind) in kinds { + let mut doc = Document::new(200.0, 100.0); + let art = envelope_node(&mut doc, kind.clone(), 50.0, false, EnvelopeOptions::NEW); + doc.symbols.push(Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Bent".into(), xf: Affine::translate((10.0, 5.0)) }); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{name}: {warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let symbol = back.symbols.iter().find(|s| s.name == "Bent").unwrap_or_else(|| panic!("{name}: {:?}", back.symbols)); + let mut envelopes = vec![]; + symbol.art.walk(&mut |n| { + if let NodeKind::Envelope { kind, .. } = &n.kind { + envelopes.push((**kind).clone()); + } + }); + assert!(matches!(envelopes.as_slice(), [k] if same_envelope_kind(&kind, k)), "{name}: {envelopes:?} {notes:?}"); + } +} + +/// An envelope with an opacity mask comes back live with its mask, and the plugin's `XP` follows +/// the envelope's dictionary rather than its mask's art. +#[test] +fn an_envelope_with_an_opacity_mask_is_read_back_live() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let mut doc = Document::new(200.0, 100.0); + let mut envelope = envelope_node(&mut doc, kind.clone(), 50.0, false, EnvelopeOptions::NEW); + let gray = square(&mut doc, Rect::new(40.0, 20.0, 160.0, 80.0), Color::gray(0.5)); + let art = group(&mut doc, vec![gray], false); + envelope.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + let text = program(&doc); + let (xp, mask) = (text.find(") 1 0 0 XP").unwrap_or(0), text.find("(AI9_Mask)").unwrap_or(usize::MAX)); + assert!(mask < xp, "{text}"); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let NodeKind::Envelope { kind: got, .. } = &back.kind else { panic!("{back:#?} {notes:?}") }; + assert!(same_envelope_kind(&kind, got), "{got:?}"); + assert!(back.extras().mask.is_some(), "{back:#?}"); +} + +/// As many envelopes are written live as the reader checks in one file, and the ones after them +/// as their art, with a warning. +#[test] +fn envelopes_past_the_readers_checks_are_written_as_their_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let limit = crate::import::ENVELOPE_CHECKS; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for _ in 0..=limit { + let points = [(30.0, 10.0), (170.0, 0.0), (30.0, 90.0), (170.0, 90.0)].map(kurbo::Point::from).to_vec(); + let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }; + let envelope = envelope_node(&mut doc, kind, 50.0, false, EnvelopeOptions::NEW); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + } + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let a = back.layers[0].children().unwrap(); + let live = a.iter().filter(|n| matches!(n.kind, NodeKind::Envelope { .. })).count(); + assert_eq!((a.len(), live), (limit + 1, limit), "{notes:?}"); + assert!(!notes.iter().any(|n| n == crate::import::ENVELOPE_DRAWN), "{notes:?}"); +} + +/// Every Envelope Options switch and the ends of Fidelity come back as saved. +#[test] +fn envelope_options_and_fidelity_are_read_back() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, PreserveShape}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let new = EnvelopeOptions::NEW; + let all = [ + (new, 50.0), + (EnvelopeOptions { anti_alias: false, ..new }, 0.0), + (EnvelopeOptions { preserve_shape: PreserveShape::Transparency, ..new }, 100.0), + (EnvelopeOptions { distort_linear_gradients: true, distort_pattern_fills: true, ..new }, 30.0), + (EnvelopeOptions { distort_appearance: false, ..new }, 50.0), + ]; + for (options, fidelity) in all { + let (back, _, notes) = envelope_back(&envelope_doc(kind.clone(), fidelity, false, options)); + let NodeKind::Envelope { options: o, fidelity: f, .. } = &back.kind else { panic!("{options:?}: {back:#?} {notes:?}") }; + assert_eq!((*o, *f), (options, fidelity)); + } +} + +/// With Distort Appearance, an envelope whose contents map from their bounds with their strokes +/// saves that box's size, and opens mapping from the same box. +#[test] +fn an_envelope_mapping_from_its_contents_strokes_is_read_back() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, envelope_content_boxes, envelope_stroke_margin}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut envelope = envelope_node( + &mut doc, + EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }, + 50.0, + false, + EnvelopeOptions::NEW, + ); + let NodeKind::Envelope { content, options, .. } = &mut envelope.kind else { unreachable_envelope() }; + for c in content.iter_mut() { + let fill = Arc::make_mut(c).appearance.fill_paint(); + Arc::make_mut(c).appearance = Appearance::basic(fill, solid(Color::BLACK), 8.0).into(); + } + let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY).unwrap(); + options.bounds_margin = envelope_stroke_margin(stroked, plain).unwrap(); + let margin = options.bounds_margin; + assert!(margin.iter().all(|m| *m > 0.0), "{margin:?}"); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let NodeKind::Envelope { options, .. } = &back.kind else { panic!("{back:#?} {notes:?}") }; + assert!(options.bounds_margin.iter().zip(margin).all(|(a, b)| (a - b).abs() < 1e-4), "{:?} {margin:?}", options.bounds_margin); +} + +/// A test's stand-in for a node that must be an envelope. +fn unreachable_envelope() -> ! { + panic!("not an envelope") +} + +/// The record is written in the order the reader reads it: on `%_` lines the group holding the +/// grid, its dictionary with the options, the contents' block and the kind, then the distorted art +/// on plain lines and the plugin's `XP`. While the contents are edited, the group holds them and +/// the dictionary the grid. +#[test] +fn an_envelope_is_written_in_the_order_the_reader_reads_it() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + for editing in [false, true] { + let text = program(&envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW)); + let at = |s: &str| text.find(s).unwrap_or_else(|| panic!("{s} missing: {text}")); + let (group, mesh, dictionary, block, key, style, xp) = ( + at("%_u\n"), + at("%_/Mesh X!"), + at("%_131073 /Int (AIDeformOptionsKey) ,\n%_50 /Int (AIDeformFidelityKey) ,\n"), + at("%_X=\n"), + at("%_ (AIDeformArtKey) ,\n"), + at("%_2 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\n"), + at("(Adobe Deform Plugin) 1 0 0 XP\n"), + ); + assert!(group < dictionary && dictionary < block && block < key && key < style && style < xp, "{text}"); + assert_eq!(block < mesh, editing, "{text}"); + assert!(text.contains(&format!("%_{} /Bool (AIDeformEditModeKey) ,\n", u8::from(editing))), "{text}"); + // The distorted art is one group on plain lines, named as the envelope, right before the `XP`. + let drawn = &text[style..xp]; + assert!(drawn.contains("\nu\n") && drawn.contains("(Bent) ; (AI10_ArtUID) ,"), "{drawn}"); + // The contents are on `%_` lines only: drawn once on plain lines, as the distorted art. + assert_eq!(text.matches("(Adobe Deform Plugin)").count(), 1, "{text}"); + } +} + +/// Envelopes the reader would open in another shape, or not at all, are written as their distorted +/// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn +/// without its style's net, a mesh the reader would lay out the other way round, a top object +/// whose sides don't pair up, a Fidelity that isn't a whole number and bounds margins kept with +/// Distort Appearance off. Other live objects keep their own warning. +#[test] +fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let base = + || envelope_doc(EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }, 50.0, false, EnvelopeOptions::NEW); + let change = |f: &dyn Fn(&mut NodeKind)| { + let mut doc = base(); + let id = doc.layers[0].children().unwrap()[0].id; + f(&mut doc.node_mut(id).unwrap().kind); + doc + }; + let typed = change(&|k| { + if let NodeKind::Envelope { content, .. } = k { + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "Type", vectorcraft_doc::CharStyle::default()); + content.push(Arc::new(Node::new(vectorcraft_doc::NodeId(9_000), NodeKind::Text(Box::new(t))))); + } + }); + let turned = change(&|k| { + if let NodeKind::Envelope { frame, .. } = k { + **frame = Affine::rotate(0.3); + } + }); + let old_warp = change(&|k| { + if let NodeKind::Envelope { kind, .. } = k { + **kind = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; + } + }); + // Columns down the page and rows across: the reader turns the grid round. + let sideways = change(&|k| { + if let NodeKind::Envelope { kind, .. } = k { + let points = + (0..=3).flat_map(|r| (0..=2).map(move |c| kurbo::Point::new(30.0 + 70.0 * f64::from(c), 10.0 + 80.0 * f64::from(r) / 3.0))).collect(); + **kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: transpose(points, 3, 2), handles: vec![] }; + } + }); + let mut uneven = kurbo::BezPath::new(); + uneven.move_to((30.0, 10.0)); + for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] { + uneven.line_to(p); + } + uneven.close_path(); + let lopsided = change(&|k| { + if let NodeKind::Envelope { kind, .. } = k { + **kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven), corners: None }; + } + }); + let fractional = change(&|k| { + if let NodeKind::Envelope { fidelity, .. } = k { + *fidelity = 50.5; + } + }); + let margin_kept = change(&|k| { + if let NodeKind::Envelope { options, .. } = k { + options.distort_appearance = false; + options.bounds_margin = [0.5; 4]; + } + }); + let cases = [ + ("type", typed), + ("turned", turned), + ("old warp", old_warp), + ("sideways mesh", sideways), + ("lopsided top object", lopsided), + ("fractional fidelity", fractional), + ("margin kept", margin_kept), + ]; + for (what, doc) in cases { + let text = program(&doc); + assert!(!text.contains("AIDeformOptionsKey"), "{what}: {text}"); + let (back, warnings, _) = envelope_back(&doc); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::LIVE), "{what}: {warnings:?}"); + assert!(matches!(back.kind, NodeKind::Group { .. }), "{what}: {back:#?}"); + } + let blend = export_ai_editing(&blend_doc(), &opts()).unwrap(); + assert!(blend.warnings.iter().any(|w| w == writer::LIVE) && !blend.warnings.iter().any(|w| w == writer::ENVELOPES), "{:?}", blend.warnings); +} + +/// `points`, a grid of `rows + 1` rows of `cols + 1` points, as `cols + 1` rows of `rows + 1`. +fn transpose(points: Vec, rows: usize, cols: usize) -> Vec { + (0..=cols).flat_map(|c| (0..=rows).map(move |r| (r, c))).map(|(r, c)| points[r * (cols + 1) + c]).collect() +} + +/// An envelope with its contents in an opacity mask's art is written as its art there, and an +/// envelope in a group stays live. +#[test] +fn envelopes_in_masks_are_written_as_their_art_and_in_groups_stay_live() { + let doc = envelopes_document(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let mut live = 0; + back.walk(|n| live += usize::from(matches!(n.kind, NodeKind::Envelope { .. }))); + assert_eq!(live, 3, "{notes:?}"); +} + +/// A warp, a mesh envelope with its contents edited, a top object in a group, and an envelope in an +/// opacity mask's art. +fn envelopes_document() -> Document { + use vectorcraft_doc::live::EnvelopeOptions; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let kinds = live_envelope_kinds(); + let kind = |name: &str| kinds.iter().find(|(n, _)| *n == name).map(|(_, k)| k.clone()).unwrap(); + let warp = envelope_node(&mut doc, kind("arc"), 50.0, false, EnvelopeOptions::NEW); + let mesh = envelope_node(&mut doc, kind("mesh"), 50.0, true, EnvelopeOptions::NEW); + let top = envelope_node(&mut doc, kind("six-anchor top object"), 50.0, false, EnvelopeOptions::NEW); + let top = group(&mut doc, vec![top], false); + let mut masked = square(&mut doc, Rect::new(0.0, 0.0, 200.0, 100.0), Color::gray(0.5)); + let mask_art = envelope_node(&mut doc, kind("twist"), 50.0, false, EnvelopeOptions::NEW); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, false))); + for n in [warp, mesh, top, masked] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + doc +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index c78c3e6542..cd0455f10f 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -16,6 +16,7 @@ use crate::ai_ops::{self, unit}; use crate::ps; mod brushes; +mod envelopes; mod images; mod paints; mod styles; @@ -25,6 +26,8 @@ mod variables; #[cfg(test)] pub(super) use brushes::BRUSHES; +#[cfg(test)] +pub(super) use envelopes::ENVELOPES; 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}; @@ -52,8 +55,7 @@ pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fil pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps"; pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps"; 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"; +pub(super) const LIVE: &str = "blends, meshes, repeats, placed documents and intertwines are written as their 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"; @@ -262,6 +264,12 @@ pub(super) struct Writer<'a> { brush_set: brushes::Brushes, /// Brushes may be written; false on a large canvas, where they are left out. pub(super) brushes_live: bool, + /// The object just written is an envelope's distorted art, which the plugin's `XP` follows + /// ([`Self::envelope`]). + deform: bool, + /// How many more envelopes may be written live: as many as the reader checks in one file + /// ([`crate::import::ENVELOPE_CHECKS`]). + envelopes_left: usize, } impl<'a> Writer<'a> { @@ -319,6 +327,8 @@ impl<'a> Writer<'a> { in_look: false, brush_set: brushes::Brushes::default(), brushes_live: true, + deform: false, + envelopes_left: crate::import::ENVELOPE_CHECKS, objects: 0, left_out: 0, definition_objects: 0, @@ -694,29 +704,16 @@ impl<'a> Writer<'a> { } self.end_group("U", xy); } - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => { - if !self.open_group("u") { - return; - } - self.warn(LIVE); - if self.nest < MAX_NEST { - let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); - self.nest += 1; - for c in g.children().map(Vec::as_slice).unwrap_or_default() { - self.node(c); - } - self.nest -= 1; - } - self.end_group("U", xy); + NodeKind::Envelope { .. } if self.envelope(n, xy) => {} + NodeKind::Envelope { .. } => self.drawn_art(n, xy, Some(envelopes::ENVELOPES)), + NodeKind::Blend { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { + self.drawn_art(n, xy, Some(LIVE)); } // Ruler guides are kept in the document's native copy only. NodeKind::RulerGuide { .. } => {} } + // Taken before the dictionary, whose opacity mask art may hold envelopes of its own. + let deform = std::mem::take(&mut self.deform); if self.body.len() > start { // A group's dictionary goes between its end and its state block. let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); @@ -726,6 +723,29 @@ impl<'a> Writer<'a> { self.body.push_str(&b); } } + // An envelope's plugin `XP` follows its distorted art, after the art's state block. + if deform { + self.line(&envelopes::plugin_line()); + } + } + + /// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given. + fn drawn_art(&mut self, n: &Node, xy: Xy, warning: Option<&str>) { + if !self.open_group("u") { + return; + } + if let Some(w) = warning { + self.warn(w); + } + if self.nest < MAX_NEST { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.nest += 1; + for c in g.children().map(Vec::as_slice).unwrap_or_default() { + self.node(c); + } + self.nest -= 1; + } + self.end_group("U", xy); } /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs new file mode 100644 index 0000000000..dcd7274291 --- /dev/null +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -0,0 +1,395 @@ +//! Envelopes (see [`crate::ai_editing`] for the form), written in the form VectorCraft's `.ai` +//! reader opens as live envelopes again: on `%_` lines a group holding the grid that shapes the +//! envelope (while its contents are edited, the contents), then that group's `/ArtDictionary` with +//! Envelope Options as a bit word (`AIDeformOptionsKey`), Fidelity (`AIDeformFidelityKey`), Edit +//! Contents (`AIDeformEditModeKey`), a warp's style, bend, distortions and orientation +//! (`DeformStyle`, `DeformValue`, `DeformHoriz`, `DeformVert`, `Rotate`), with Distort Appearance +//! the size of the box the contents map from (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the +//! contents in an `X=` … `X+` block (while they are edited, the grid) as `AIDeformArtKey`, and the +//! kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object); then the distorted art on plain +//! lines, and the plugin's `XP` after it. A top object's outline is the edge of the grid written +//! for it, one grid edge to each segment of its sides. An envelope the reader would open in another +//! shape is written as its distorted art. + +use std::fmt::Write; +use std::sync::Arc; + +use kurbo::{Point, Vec2}; +use vectorcraft_doc::live::{ + Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes, + envelope_grid, envelope_stroke_margin, warp_grid, +}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, PathData}; + +use super::{MAX_NEST, Writer, Xy, hide}; +use crate::import::{DEFORM_PLUGIN, EnvelopeGridPoint as GridPoint, envelope_outline, envelope_upright, warp_number}; +use crate::ps; + +pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes of type, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; + +/// The most rows or columns of an envelope's grid written (Make with Mesh's own limit, and the +/// reader's). +const MAX_GRID: usize = 50; +/// How far, as a share of the diagonal of a top object's patch, the patch on the outline written for +/// it may lie from its own at any of the places checked. +const TOP_OBJECT_TOLERANCE: f64 = 1e-6; +/// How far apart two bounds margins may be and still count as the same. +const MARGIN_SLACK: f32 = 1e-5; + +/// What an envelope's dictionary holds besides its contents: the entries before the contents' block, +/// each on a line of its own, the kind (`AIDeformStyle`) and the grid that shapes it (`rows`, `cols` +/// and its points with their handles, row by row from the top left, in the document), when it has +/// one. +struct Record { + keys: String, + style: u8, + grid: Option<(usize, usize, Vec)>, +} + +/// The `XP` that ends an envelope, after its distorted art. +pub(super) fn plugin_line() -> String { + format!("{} 1 0 0 XP", ps::bytes(DEFORM_PLUGIN.as_bytes())) +} + +/// Does `n`, or anything in it, hold type? +fn holds_type(n: &Node) -> bool { + matches!(n.kind, NodeKind::Text(_)) || n.children().is_some_and(|c| c.iter().any(|k| holds_type(k))) +} + +/// The record of envelope `n` (its parts given), or `None` where the reader would open it in +/// another shape than it has, or not as an envelope. +fn record( + n: &Node, + content: &[Arc], + kind: &EnvelopeKind, + fidelity: f64, + editing: bool, + options: &EnvelopeOptions, + frame: Affine, +) -> Option { + // The reader reads Fidelity as a whole number from 0 to 100, and bounds margins only with + // Distort Appearance on. + let whole = (0.0..=100.0).contains(&fidelity) && fidelity.fract() == 0.0; + let margin_kept = !options.distort_appearance && options.bounds_margin != [0.0; 4]; + if content.is_empty() || frame != Affine::IDENTITY || !whole || margin_kept || content.iter().any(|c| holds_type(c)) { + return None; + } + let mut keys = String::new(); + let bit = |on: bool, at: u32| i64::from(on) << at; + let word = bit(options.distort_appearance, 0) + | bit(options.distort_pattern_fills, 1) + | bit(options.distort_linear_gradients, 2) + | bit(!options.anti_alias, 16) + | bit(options.preserve_shape == PreserveShape::ClippingMask, 17); + let _ = writeln!(keys, "{word} /Int (AIDeformOptionsKey) ,"); + let _ = writeln!(keys, "{} /Int (AIDeformFidelityKey) ,", fidelity as i64); + let _ = writeln!(keys, "{} /Bool (AIDeformEditModeKey) ,", u8::from(editing)); + let real = |keys: &mut String, v: f64, key: &str| { + ps::push_num_places(keys, v, 9); + let _ = writeln!(keys, " /Real ({key}) ,"); + }; + let (style, grid) = match kind { + EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => { + let fraction = |p: f64| (p.is_finite() && (-100.0..=100.0).contains(&p)).then_some(p / 100.0); + let (number, bend, h, v) = (warp_number(style)?, fraction(*bend)?, fraction(*h)?, fraction(*v)?); + if *version != WARP_NET { + return None; + } + let _ = writeln!(keys, "{number} /Int (DeformStyle) ,"); + real(&mut keys, h, "DeformHoriz"); + real(&mut keys, bend, "DeformValue"); + real(&mut keys, v, "DeformVert"); + let _ = writeln!(keys, "{} /Bool (Rotate) ,", u8::from(!*horizontal)); + // The grid of the warp's shape, for apps that read it. + (1, warp_grid(n).and_then(|m| as_read(&m)).and_then(|(rows, cols, pts)| sized(rows, cols).map(|(r, c)| (r, c, pts)))) + } + EnvelopeKind::Mesh { rows, cols, points, handles } => { + let (r, c) = sized(*rows, *cols)?; + let mesh = envelope_grid(*rows, *cols, points, handles)?; + let pts = as_read(&mesh)?.2; + // The reader lays the grid out with its first row at the top and its first column at the + // left: a grid it would lay out another way round would map the contents another way. + if !reads_as_written(r, c, &pts) { + return None; + } + (2, Some((r, c, pts))) + } + EnvelopeKind::TopObject { path, corners } => (3, Some(top_object_grid(path, *corners)?)), + }; + if options.distort_appearance { + let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY)?; + let margin = options.bounds_margin; + let same = |m: [f32; 4]| m.iter().zip(margin).all(|(a, b)| (a - b).abs() <= MARGIN_SLACK); + // The reader maps from the size written when it matches the contents' bounds with their + // strokes, and from their geometric bounds otherwise. + let size = if margin == [0.0; 4] { + plain + } else if stroked != plain && envelope_stroke_margin(stroked, plain).is_some_and(same) { + stroked + } else { + return None; + }; + real(&mut keys, size.height(), "AIDeformArtHeightKey"); + real(&mut keys, size.width(), "AIDeformArtWidthKey"); + } + Some(Record { keys, style, grid }) +} + +/// `rows` and `cols` as sizes, when the reader reads a grid of them. +fn sized(rows: u32, cols: u32) -> Option<(usize, usize)> { + let (r, c) = (usize::try_from(rows).ok()?, usize::try_from(cols).ok()?); + ((1..=MAX_GRID).contains(&r) && (1..=MAX_GRID).contains(&c)).then_some((r, c)) +} + +/// The points of grid `m` with their handles as the reader reads them back: the handles that point +/// out of the grid, which nothing draws with, are left at the point. `None` for a grid of points +/// or handles that aren't finite. +fn as_read(m: &GradientMesh) -> Option<(u32, u32, Vec)> { + let (rows, cols) = (m.rows as usize, m.cols as usize); + let finite = |v: Vec2| v.x.is_finite() && v.y.is_finite(); + let mut pts = Vec::with_capacity(m.points.len()); + for (i, q) in m.points.iter().enumerate() { + if !finite(q.p.to_vec2()) || !q.handles.iter().all(|h| finite(*h)) { + return None; + } + let (r, c) = (i / (cols + 1), i % (cols + 1)); + let mut h = q.handles; + for (slot, out) in [(H_LEFT, c == 0), (H_RIGHT, c == cols), (H_UP, r == 0), (H_DOWN, r == rows)] { + if out && let Some(h) = h.get_mut(slot) { + *h = Vec2::ZERO; + } + } + pts.push((q.p, h)); + } + Some((m.rows, m.cols, pts)) +} + +/// Does the reader lay out grid `pts` (`rows`×`cols` patches) as it is written? +fn reads_as_written(rows: usize, cols: usize, pts: &[GridPoint]) -> bool { + let points = + pts.iter().map(|(p, handles)| MeshPoint { p: *p, color: vectorcraft_color::Color::BLACK, opacity: 1.0, handles: *handles }).collect(); + let mesh = GradientMesh { rows: rows as u32, cols: cols as u32, points }; + envelope_upright(&mesh).is_some_and(|(r, c, read)| (r as usize, c as usize) == (rows, cols) && read == pts) +} + +/// The grid a top object with outline `path` and corners `corners` is written with: its edge runs +/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, so +/// the top and bottom sides need as many segments as each other, as do the left and right sides; +/// the points inside blend the sides. `None` where that grid's edge, read back as the top object's +/// outline, doesn't give the same patch ([`Coons::from_top_object`]) at every place checked. +fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, usize, Vec)> { + let coons = Coons::from_top_object(path, corners)?; + // The outline, as the patch takes it: the largest subpath. + let mut sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?.clone(); + let count = sp.anchors.len(); + let empty = |i: usize| { + let c = sp.segment(i); + [c.p1, c.p2, c.p3].iter().all(|p| p.distance(c.p0) <= 1e-9) + }; + if !sp.closed || count < 4 || (0..count).any(empty) { + return None; + } + let fits = |c: &[usize; 4]| { + c.iter().all(|i| *i < count) && { + let gaps = (0..4).map(|k| (c[(k + 1) % 4] + count - c[k]) % count); + gaps.clone().all(|g| g > 0) && gaps.sum::() == count + } + }; + // Stored corners that fit keep the outline as drawn; otherwise it runs clockwise, and the + // corners are the anchors the patch's corners lie on. + let at = match corners.map(|c| c.map(|i| i as usize)).filter(fits) { + Some(c) => c, + None => { + if sp.signed_area() < 0.0 { + sp.reverse(); + } + let find = |q: Point| sp.anchors.iter().position(|a| a.p.distance(q) <= 1e-9); + let [tl, tr, br, bl] = coons.corners(); + let c = [find(tl)?, find(tr)?, find(br)?, find(bl)?]; + if !fits(&c) { + return None; + } + c + } + }; + let gap = |k: usize| (at[(k + 1) % 4] + count - at[k]) % count; + let (cols, rows) = (gap(0), gap(1)); + if gap(2) != cols || gap(3) != rows || cols > MAX_GRID || rows > MAX_GRID { + return None; + } + let width = cols + 1; + let mut pts: Vec = vec![(Point::ZERO, [Vec2::ZERO; 4]); (rows + 1) * width]; + // Where the `k`th anchor from the top left corner goes, and the slots of the handles of the + // segment from it to the next anchor: its own, and the next anchor's back. + let place = |k: usize| -> (usize, usize, usize, usize) { + if k < cols { + (0, k, H_RIGHT, H_LEFT) + } else if k < cols + rows { + (k - cols, cols, H_DOWN, H_UP) + } else if k < 2 * cols + rows { + (rows, cols - (k - cols - rows), H_LEFT, H_RIGHT) + } else { + (rows - (k - 2 * cols - rows), 0, H_UP, H_DOWN) + } + }; + for k in 0..count { + let i = (at[0] + k) % count; + let seg = sp.segment(i); + // A straight segment's patch spreads evenly along it, as a cubic's with its handles a third + // of the way does. + let (p1, p2) = if sp.segment_is_line(i) { (seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0)) } else { (seg.p1, seg.p2) }; + let (r, c, out, into) = place(k); + let (nr, nc, _, _) = place((k + 1) % count); + if let Some(q) = pts.get_mut(r * width + c) { + q.0 = seg.p0; + q.1[out] = p1 - seg.p0; + } + if let Some(q) = pts.get_mut(nr * width + nc) { + q.1[into] = p2 - seg.p3; + } + } + // The points inside, blended from the sides. + let edge = |r: usize, c: usize| pts.get(r * width + c).map_or(Point::ZERO, |q| q.0).to_vec2(); + let mut inside = vec![]; + for r in 1..rows { + for c in 1..cols { + let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); + let p = (1.0 - v) * edge(0, c) + v * edge(rows, c) + (1.0 - u) * edge(r, 0) + u * edge(r, cols) + - ((1.0 - u) * (1.0 - v) * edge(0, 0) + u * (1.0 - v) * edge(0, cols) + (1.0 - u) * v * edge(rows, 0) + u * v * edge(rows, cols)); + inside.push((r * width + c, p.to_point())); + } + } + for (i, p) in inside { + if let Some(q) = pts.get_mut(i) { + q.0 = p; + } + } + if !pts.iter().all(|(p, h)| p.is_finite() && h.iter().all(|h| h.is_finite())) || !reads_as_written(rows, cols, &pts) { + return None; + } + // The top object the reader makes of the grid: its edge, with the corners of the grid. + let outline = envelope_outline(rows as u32, cols as u32, &pts)?; + let corners = [0, cols, cols + rows, 2 * cols + rows].map(|c| c as u32); + let read = Coons::from_top_object(&outline, Some(corners))?; + let [tl, tr, br, bl] = coons.corners(); + let reach = tl.distance(br).max(tr.distance(bl)).max(1.0) * TOP_OBJECT_TOLERANCE; + const STEPS: usize = 8; + let at = |i: usize| i as f64 / STEPS as f64; + let same = (0..=STEPS).all(|j| (0..=STEPS).all(|i| coons.eval(at(i), at(j)).distance(read.eval(at(i), at(j))) <= reach)); + same.then_some((rows, cols, pts)) +} + +impl Writer<'_> { + /// Envelope `n`, of transparency `xy`, as a live envelope (see the module's form), outside opacity + /// masks' art, pattern tiles, art cut to a clipping object and the layers of a large canvas. False, + /// writing nothing, where it is written as its art instead: where the reader would open it in + /// another shape, or some of it is left out for the object limit ([`Self::take_objects`]). + pub(super) fn envelope(&mut self, n: &Node, xy: Xy) -> bool { + let NodeKind::Envelope { content, kind, fidelity, editing, options, frame } = &n.kind else { return false }; + if self.hidden > 0 || self.clip.is_some() || self.nest >= MAX_NEST || !self.symbols_live || self.envelopes_left == 0 { + return false; + } + let Some(record) = record(n, content, kind, *fidelity, *editing, options, **frame) else { return false }; + let start = self.body.len(); + let (state, gs, charges, bound) = (self.state, self.gs, self.charges(), self.bound.len()); + self.nest += 1; + self.hidden += 1; + // The group before the dictionary: the grid, or the contents while they are edited. + let outer = std::mem::take(&mut self.body); + let opened = self.open_group("u"); + if opened { + if *editing { + self.contents(content); + } else { + self.grid(record.grid.as_ref()); + } + self.line("U"); + } + let before = std::mem::replace(&mut self.body, outer); + hide(&before, &mut self.body); + self.body.push_str("%_/ArtDictionary :\n"); + hide(&record.keys, &mut self.body); + self.body.push_str("%_X=\n"); + // The dictionary's art starts unlocked, shown and opaque, as an opacity mask's does. + let outer = std::mem::take(&mut self.body); + let at_block = self.state; + (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); + if *editing { + self.grid(record.grid.as_ref()); + } else if self.open_group("u") { + self.contents(content); + self.end_group("U", Xy::PLAIN); + } + let block = std::mem::replace(&mut self.body, outer); + hide(&block, &mut self.body); + (self.state, self.gs, self.block_end) = (at_block, None, usize::MAX); + let _ = writeln!(self.body, "%_X+\n%_ (AIDeformArtKey) ,\n%_{} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW", record.style); + self.hidden -= 1; + self.nest -= 1; + // The distorted art, which takes the envelope's lock, visibility, transparency, name and + // opacity mask. + self.state(n.locked, !n.visible); + self.drawn_art(n, xy, None); + if !opened || self.left_out_since(charges) { + let lines = self.body.split_off(start); + self.give_back(charges, &lines); + self.bound.truncate(bound); + (self.state, self.gs, self.block_end) = (state, gs, usize::MAX); + return false; + } + self.deform = true; + self.envelopes_left -= 1; + true + } + + /// An envelope's contents. + fn contents(&mut self, content: &[Arc]) { + for c in content { + self.node(c); + } + } + + /// An envelope's grid, when it has one, as a gray gradient mesh (`/Mesh X!` … `/End X!`): its + /// size (`[cols rows] /Size`), then each patch's place (`[col row] /P`), its four corners + /// (`/N`: gray, opacity, the point, the handle towards the next corner and a weight, the handle + /// towards the previous one and a weight, in art space), from its top left one down, across and + /// up, and `/E`. One object. + fn grid(&mut self, grid: Option<&(usize, usize, Vec)>) { + let Some((rows, cols, pts)) = grid else { return }; + if !self.take_objects(1) { + return; + } + let (rows, cols, width) = (*rows, *cols, cols + 1); + let to_art = self.to_art; + let s = &mut self.body; + let _ = writeln!(s, "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[{cols} {rows}] /Size X#\n/Data X#\n/DeviceGray /CS X#"); + let num = |s: &mut String, v: f64| { + s.push(' '); + ps::push_num(s, v); + }; + for c in 0..cols { + for r in 0..rows { + let _ = writeln!(s, "[{c} {r}] /P X#"); + // Each corner with the slots of its handles towards the next corner and the previous one. + let corners = [((r, c), H_DOWN, H_RIGHT), ((r + 1, c), H_RIGHT, H_UP), ((r + 1, c + 1), H_UP, H_LEFT), ((r, c + 1), H_LEFT, H_DOWN)]; + for ((pr, pc), next, prev) in corners { + let (p, h) = pts.get(pr * width + pc).copied().unwrap_or((Point::ZERO, [Vec2::ZERO; 4])); + let at = |v: Vec2| to_art * (p + v); + s.push_str("[0 1"); + for q in [at(Vec2::ZERO), at(h[next])] { + num(s, q.x); + num(s, q.y); + } + s.push_str(" 1"); + let q = at(h[prev]); + num(s, q.x); + num(s, q.y); + s.push_str(" 1] /N X#\n"); + } + s.push_str("/E X#\n"); + } + } + s.push_str("/End X!\n"); + } +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 2cc4bed521..bdb3b9cf50 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -82,12 +82,15 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; +#[cfg(test)] +pub(crate) use ai::ENVELOPE_DRAWN; pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; pub(crate) use ai::{ - 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, + BRUSH_MANAGER, BrushesRead, DEFORM_PLUGIN, EnvelopeGridPoint, MAX_CHECKED as ENVELOPE_CHECKS, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, + MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, envelope_outline, envelope_upright, 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, record_version}; 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}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 2d6c4a533d..a3de7560e3 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -36,9 +36,9 @@ 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; pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN; +pub(crate) use envelope::{GridPoint as EnvelopeGridPoint, PLUGIN as DEFORM_PLUGIN, outline as envelope_outline, upright as envelope_upright}; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub use obj::{NameEncoding, Names}; diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index edb9bba3e3..741ebfabdf 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -41,7 +41,7 @@ use super::obj::Obj; use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour, differ_on_black}; /// The plugin that writes envelopes. -pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use +pub(crate) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use /// The note for an envelope that keeps its drawn art. pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art"; /// The note for an envelope of type, which keeps its drawn art with its type hidden (unless the @@ -366,12 +366,12 @@ fn only_mesh(n: &Node) -> Option<&GradientMesh> { } /// A grid point and its handles. -type GridPoint = (Point, [Vec2; 4]); +pub(crate) type GridPoint = (Point, [Vec2; 4]); /// The points of mesh `m` as a `rows`×`cols` grid with its first row at the top and its first /// column at the left (y down), as an envelope's mesh lays them out; `None` for a grid out of range /// or with points that aren't finite. -fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { +pub(crate) fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { let (mut rows, mut cols) = (m.rows, m.cols); if rows == 0 || cols == 0 || rows > MAX_GRID || cols > MAX_GRID { return None; @@ -451,7 +451,7 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { } /// The outline of an upright grid: its edge, clockwise from the top left corner. -fn outline(rows: u32, cols: u32, pts: &[GridPoint]) -> Option { +pub(crate) fn outline(rows: u32, cols: u32, pts: &[GridPoint]) -> Option { let at = |r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).copied(); // Each edge: from a point by one of its handles to the next point by one of its handles. type Edge = ((u32, u32), usize, (u32, u32), usize); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..3e0a981b18 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1534,6 +1534,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out).