From 87c8def043f3175338810c15ec53ba081f29a8e4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 18:50:43 -0800 Subject: [PATCH] .ai save: a linked image with a Warp keeps its live Warp and its link A linked image whose appearance is one Warp effect, with no opacity mask, was saved to the .ai editing data as its drawn art, so its live Warp was lost. It is now written with an art style of that Warp, as an embedded image is: its bent pieces are its drawn look, and the image itself follows on %_ lines as a placed file with its path, its folder relative to the document's and its preview. In an opacity mask's art the copy is embedded, with the existing warning for linked images in masks. Opening such a file reads the image back linked, with its Warp live. A hidden image with a Warp, embedded or linked, is now compared with its drawn look shown, so it keeps its Warp too; before, the look drew nothing and the image came in as its drawn pieces. --- crates/eps/src/ai_editing/tests.rs | 155 +++++++++++++++++++-- crates/eps/src/ai_editing/writer.rs | 4 + crates/eps/src/ai_editing/writer/images.rs | 5 +- crates/eps/src/ai_editing/writer/styles.rs | 23 +-- crates/eps/src/import/ai/read/ops.rs | 15 +- docs/target-app-parity.md | 3 +- 6 files changed, 182 insertions(+), 23 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 967c982ad4..0f58e00181 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3255,7 +3255,8 @@ fn images_are_written_again_as_the_same_bytes() { /// The program of `doc` with LF line ends, and each image's samples (from after `XI` and its line /// end to `%%EndData`, a `%_` before it kept) and each preview's rows (from after `32 17 ` to the -/// line end before `%AI26_EndPlacedObjectPreview`) shown as ``. +/// line end before `%AI26_EndPlacedObjectPreview` or `%_%AI26_EndPlacedObjectPreview`) shown as +/// ``. fn program_shown(doc: &Document) -> String { shown(&export_ai_editing(doc, &opts()).unwrap().program) } @@ -3267,10 +3268,13 @@ fn shown(p: &[u8]) -> String { let mut at = 0; loop { let xi = find(at, b"\r\nXI\r\n").map(|i| (i + 6, b"%%EndData".as_slice())); - let pv = find(at, b" 32 17 ").map(|i| (i + 7, b"\r\n%AI26_EndPlacedObjectPreview".as_slice())); + let pv = find(at, b" 32 17 ").map(|i| (i + 7, b"%AI26_EndPlacedObjectPreview".as_slice())); let Some((start, end)) = [xi, pv].into_iter().flatten().min_by_key(|(s, _)| *s) else { break }; let mut stop = find(start, end).unwrap(); - if end.starts_with(b"%%") && p.get(stop - 2..stop) == Some(b"%_") { + if p.get(stop - 2..stop) == Some(b"%_") { + stop -= 2; + } + if end.starts_with(b"%AI26") && p.get(stop - 2..stop) == Some(b"\r\n") { stop -= 2; } out.extend_from_slice(&p[at..start]); @@ -7354,18 +7358,147 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() { assert_eq!(art[0].name.as_deref(), Some("Photo")); } -/// A linked image, an image with an opacity mask, and an image with a Warp and another effect are -/// written as their drawn art, with no art style. +/// [`warped_photo`] linked to `/Users/someone/Job/art/photo.png`, in folder `art` next to the +/// document's. +fn linked_warped_photo(doc: &mut Document, effects: Vec) -> Node { + let mut n = warped_photo(doc, effects); + if let NodeKind::Image(im) = &mut n.kind { + let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job/art/photo.png"); + link.relative = Some("art/photo.png".into()); + im.link = Some(link); + } + n +} + +/// A linked image with one Warp is written with an art style of that Warp, its bent pieces as its +/// drawn look and the image itself as a placed file on `%_` lines, with its path, folder and +/// preview: it reads back as the linked image, its pixels, placement, name, opacity and link as +/// they were, with its Warp live and its settings kept. +#[test] +fn a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live() { + // GUARDS: a linked image with a Warp was written as its drawn pieces alone, and read back + // without its Warp or its link. + let params = serde_json::json!({"bend": 40.0}); + let doc = photo_doc(|d| linked_warped_photo(d, vec![fx("warp.arc", params.clone())])); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let text = program_shown(&doc); + assert!(text.contains("\n%_%AI5_BeginPlace\n"), "{text}"); + assert!(text.contains("\n%_(/Users/someone/Job/art/photo.png)`\n"), "{text}"); + assert!(text.contains("\n%_(art/)\n"), "{text}"); + assert!(text.contains("\n%_8 8 32 32 17 <256 bytes>\n"), "{text}"); + assert!(text.contains("\n%_%AI5_EndPlace\n") && text.contains("\n1 (Anon 1) XW\n"), "{text}"); + assert!(!text.contains("BeginRaster\n%_"), "no embedded copy: {text}"); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{:#?}", lines(&back)); + let (mut want, mut got) = (vec![], vec![]); + pictures(&doc, doc.layers[0].children().unwrap(), "", &mut want); + pictures(&back, art, "", &mut got); + assert_eq!(got, want); + let [warp] = art[0].appearance.effects.as_slice() else { panic!("{:?}", art[0].appearance) }; + assert_eq!(warp.id, "warp.arc"); + let (w, g) = (vectorcraft_effects::merged_params("warp.arc", ¶ms), vectorcraft_effects::merged_params(&warp.id, &warp.params)); + for key in ["bend", "horizontal", "vertical"] { + assert!((g[key].as_f64().unwrap() - w[key].as_f64().unwrap()).abs() < 6e-3, "{key}: {g} for {w}"); + } +} + +/// A linked image with one Warp larger than a placed file's preview: its copy's preview and its drawn +/// look's pieces (placed files of the same image) hold the same smaller preview, so fine detail +/// reads back with the Warp live, the link kept and the preview's pixels, as a linked image without +/// a Warp does. +#[test] +fn a_large_linked_image_with_a_warp_reads_back_from_its_preview() { + // GUARDS: a linked image with a Warp was written as its drawn pieces alone. + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let checks = + image::RgbaImage::from_fn(600, 300, |x, y| if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); + let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job/art/checks.png"); + link.relative = Some("art/checks.png".into()); + let xf = Affine::new([0.25, 0.0, 0.0, 0.25, 20.0, 10.0]); + let mut n = image_node(&mut doc, ImageBlob::png(png_of(checks.into())), (600, 300), xf, Some(link)); + n.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let text = program_shown(&doc); + assert!(text.contains("\n%_256 128 1024 32 17 <131072 bytes>\n"), "the copy's preview"); + assert!(text.contains("\n256 128 1024 32 17 <131072 bytes>\n"), "the look's pieces' previews"); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{:#?}", lines(&back)); + let NodeKind::Image(im) = &art[0].kind else { panic!("{:?}", art[0].kind) }; + assert_eq!((im.width, im.height), (256, 128)); + assert_eq!( + im.link.as_ref().map(|l| (l.path.as_str(), l.relative.as_deref())), + Some(("/Users/someone/Job/art/checks.png", Some("art/checks.png"))) + ); + assert_eq!(art[0].appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["warp.arc"]); +} + +/// A hidden or locked image with one Warp, embedded or linked, keeps its state, its link and its +/// live Warp. +#[test] +fn a_hidden_or_locked_image_with_a_warp_keeps_its_state() { + // GUARDS: a linked image with a Warp was written as its drawn pieces alone, and a hidden image's + // Warp was compared with a look that drew nothing, so it came in as its drawn pieces. + for (what, make) in [("embedded", warped_photo as fn(&mut Document, Vec) -> Node), ("linked", linked_warped_photo)] { + for (shown, locked) in [(true, true), (false, false), (false, true)] { + let doc = photo_doc(|d| { + let mut n = make(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]); + (n.visible, n.locked) = (shown, locked); + n + }); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{what} {shown} {locked}: {:?}", out.warnings); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{what} {shown} {locked}: {:#?}", lines(&back)); + assert_eq!(images_of(&back).len(), 1, "{what} {shown} {locked}"); + assert_eq!(images_of(&back), images_of(&doc), "{what} {shown} {locked}"); + assert_eq!(art[0].appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["warp.arc"]); + } + } +} + +/// A linked image with one Warp in an opacity mask's art keeps its live Warp; its copy on `%_` lines +/// is embedded, as linked images in masks are, with that warning. +#[test] +fn a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live() { + // GUARDS: the placed file of a styled image written in an opacity mask's art. + let doc = photo_doc(|d| { + let art = linked_warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]); + let mut square = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0)); + square.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + square + }); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::LINKS_EMBEDDED]); + let text = program_shown(&doc); + assert!(!text.contains("BeginPlace") && text.contains("1 (Anon 1) XW"), "{text}"); + let back = read_back(&doc); + let mask = back.layers[0].children().unwrap()[0].extras().mask.as_ref().map(|m| m.art.clone()).expect("the mask"); + let NodeKind::Image(im) = &mask.kind else { panic!("{:?}", mask.kind) }; + assert!(im.link.is_none()); + assert_eq!(mask.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["warp.arc"]); +} + +/// An image with an opacity mask and an image with a Warp and another effect are written as their +/// drawn art, with no art style, linked or embedded. #[test] fn other_warped_images_are_written_as_their_drawn_art() { let arc = || fx("warp.arc", serde_json::json!({"bend": 40.0})); - let linked = photo_doc(|d| { - let mut n = warped_photo(d, vec![arc()]); - if let NodeKind::Image(im) = &mut n.kind { - im.link = Some(vectorcraft_doc::LinkInfo::new("/art/photo.png")); - } + let linked_masked = photo_doc(|d| { + let mut n = linked_warped_photo(d, vec![arc()]); + let white = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::WHITE), Paint::None, 0.0)); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, false))); n }); + let linked_stacked = photo_doc(|d| linked_warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))])); let masked = photo_doc(|d| { let mut n = warped_photo(d, vec![arc()]); let white = @@ -7374,7 +7507,7 @@ fn other_warped_images_are_written_as_their_drawn_art() { n }); let stacked = photo_doc(|d| warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))])); - for (what, doc) in [("linked", linked), ("masked", masked), ("stacked", stacked)] { + for (what, doc) in [("linked masked", linked_masked), ("linked stacked", linked_stacked), ("masked", masked), ("stacked", stacked)] { assert!(!program_shown(&doc).contains("1 (Anon"), "{what}"); let back = read_back(&doc); let art = back.layers[0].children().unwrap(); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 879eb9d9f5..765856753c 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -206,6 +206,9 @@ pub(super) struct Writer<'a> { mask_level: Option<(u32, u32)>, /// The image just written was written as a placed file. placed: bool, + /// The image being written is the copy of a styled object on the layers, on `%_` lines: linked, + /// it is written as a placed file there ([`Self::styled_object`]). + styled_copy: bool, /// The type just written was written as type, not as its outlines. typed: bool, /// The objects bound to variables so far, in the order they are written: each one's id, its XML @@ -299,6 +302,7 @@ impl<'a> Writer<'a> { hidden: 0, mask_level: None, placed: false, + styled_copy: false, typed: false, bound: vec![], texts: texts::TextDoc::default(), diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index 36c21c14eb..355f5c2709 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -94,7 +94,8 @@ impl Samples { impl Writer<'_> { /// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a /// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on - /// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is + /// `%_` lines (other than a styled object's copy on the layers, [`Self::styled_object`]) and in + /// a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is /// cut to a clipping path, the image is in a clipping group of that path. pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) { let doc = self.doc; @@ -125,7 +126,7 @@ impl Writer<'_> { let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height)); match &im.link { - Some(link) if self.hidden == 0 && !self.in_symbol => { + Some(link) if (self.hidden == 0 || self.styled_copy) && !self.in_symbol => { // Cut to a region, the placed file is in a clipping group. self.placed = cut.is_none(); self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 163d70381c..5a80e50a78 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -42,7 +42,7 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { /// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art /// style ([`Writer::styled_object`]), by id (a mask's art root shown, as [`Writer::mask`] writes /// it): 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, and embedded images with one Warp +/// style or (paths and compound paths) with a brush on a stroke, and images with one Warp /// ([`warped_image`]), 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 @@ -100,12 +100,11 @@ fn candidate(n: &Node) -> bool { } } -/// Is `n` an embedded image without an opacity mask whose appearance is one Warp, with no fills or -/// strokes that paint and no effects on them: an image the reader reads with its Warp live -/// ([`crate::import::read_art_style`]'s `image_warp`)? +/// Is `n` an image, embedded or linked, without an opacity mask whose appearance is one Warp, with +/// no fills or strokes that paint and no effects on them: an image the reader reads with its Warp +/// live ([`crate::import::read_art_style`]'s `image_warp`)? fn warped_image(n: &Node) -> bool { - let NodeKind::Image(im) = &n.kind else { return false }; - im.link.is_none() + matches!(n.kind, NodeKind::Image(_)) && n.extras().mask.is_none() && matches!(n.appearance.effects.as_slice(), [e] if e.id.starts_with("warp.")) && n.appearance.items.iter().all(|i| i.paint().is_none() && i.effects().is_empty()) @@ -725,7 +724,8 @@ impl Writer<'_> { /// glows or feathers, the object's transparency /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or /// compound path's points painted with nothing, or with its gradient fill when its style has - /// one; a group with its members; an image with its pixels), its name and opacity mask; then + /// one; a group with its members; an image with its pixels, or on the layers a linked image as + /// a placed file with its path, folder and preview), 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, and the object counts neither as written with its graphic @@ -772,6 +772,9 @@ impl Writer<'_> { // The object, on `%_` lines, in its own state (the look's members may have changed it). self.state(orig.locked, !orig.visible); let saved = std::mem::take(&mut self.body); + // On the layers, a linked image's copy is a placed file; in an opacity mask's art, it is + // embedded, as other linked images there are. + let on_layers = self.hidden == 0; self.hidden += 1; let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); // A path's or an image's objects were kept for it above; it counts them itself. @@ -781,7 +784,11 @@ impl Writer<'_> { match &orig.kind { NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), - NodeKind::Image(im) => self.image(&self.doc.shown_image(orig, im), xy), + NodeKind::Image(im) => { + let was = std::mem::replace(&mut self.styled_copy, on_layers); + self.image(&self.doc.shown_image(orig, im), xy); + self.styled_copy = was; + } NodeKind::Group { children, .. } => { self.line("u"); for c in children { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index c1a6aa0385..87ba9752ef 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1141,7 +1141,8 @@ fn image_warps(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool { /// Does image `object` with `appearance` (its style's Warp) draw as `look`, its drawn look, compared /// over all that the warp draws ([`crate::import::ai::envelope::warped_alike`]) on `layer`, a layer -/// id no other node has? Both are drawn shown, opaque and in the Normal mode. Unless the look is on +/// id no other node has? Both are drawn shown, opaque and in the Normal mode; a hidden look is drawn +/// with every object in it shown, as the hidden object's look holds them. Unless the look is on /// `%_` lines itself (`hidden`), the copies in it that are on `%_` lines (`hidden_ids`) are left out. #[allow(clippy::too_many_arguments)] fn image_warp_alike( @@ -1161,12 +1162,24 @@ fn image_warp_alike( if !hidden { strip(&mut plain, hidden_ids); } + if !plain.visible { + show_members(&mut plain); + } for n in [&mut live, &mut plain] { (n.visible, n.opacity, n.blend) = (true, 1.0, BlendMode::Normal); } crate::import::ai::envelope::warped_alike(&live, &plain, layer, shared, page, spent) } +/// Show every object in container `n`, at any depth. +fn show_members(n: &mut Node) { + for c in n.children_mut().into_iter().flatten() { + let c = Arc::make_mut(c); + c.visible = true; + show_members(c); + } +} + /// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. const PATTERN_KEY: &str = "\u{1}pattern "; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..4de05e4cb7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -620,7 +620,8 @@ since 2026-10-01. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. - **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. -- **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). A linked image with a Warp is saved as its drawn art, as before, and its live Warp is not saved. Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged. +- **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged. +- **`.ai` files keep a linked image's Warp live and its link:** a linked image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp, as an embedded one is: its bent pieces are its drawn look, and the image itself follows on `%_` lines as a placed file with its path, its folder relative to the document's and its preview. Before, it was saved as its drawn art and its live Warp was lost. Opening the file reads it as the linked image with its Warp live, its link, pixels, placement, name, opacity, hide and lock kept, under the same drawn-look comparison; a hidden image with a Warp, embedded or linked, is now compared with its look drawn shown, and keeps its Warp too. A linked image with a Warp in an opacity mask's art keeps its Warp and is embedded, with the existing warning for linked images in masks. Linked files stay connected to their source and track it by relative path ([the help page](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/manage-linked-and-embedded-files/links-panel-overview.html)); the placed-file form is the one the `.ai` reader reads (`crates/eps/src/import/ai/read/art.rs` `end_place`). eps `ai_editing::tests::a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live`, `a_large_linked_image_with_a_warp_reads_back_from_its_preview`, `a_hidden_or_locked_image_with_a_warp_keeps_its_state`, `a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live`. Scores and hours unchanged. - **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. - **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.