.ai export: the pieces of a warped image write only the pixels they show

The pieces of a Warp effect, an envelope or their expanded art each
held a copy of the whole image in the .ai editing data and in legacy
.ai files: 200 copies for a Warp's 10 x 10 mesh.

- An embedded image cut to a clipping path (in the editing data, also
  an image under an opacity mask that clips) is written whole the
  first time; later copies of the same pixels are written as the
  rectangle the path or mask shows, 2 pixels more on each side, with
  the matrix moved to keep their place. A copy that the path shows
  none of is written as one pixel.
- An opacity mask's window covers everything the mask's art paints,
  its effects and symbol art included.
- An image's editable original and an image in an opacity mask's art
  are written whole. A live Warp's drawn look is not cut to the
  clipping groups around the object, so a clipped image with a live
  Warp reads back with its Warp live.
- When art that held an image's whole copy is left out (an opacity
  mask's art, a live envelope or a definition past the object limit),
  the next copy is written whole again. A whole copy written on hidden
  lines after an earlier one leaves the earlier one as the copy that
  counts.
- The writers read an image's pixels once for the pieces that follow
  one another.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 12:11:12 -08:00
parent 08b6cfd3e8
commit 1a77932167
11 changed files with 531 additions and 26 deletions
+270
View File
@@ -7228,6 +7228,276 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() {
assert_eq!(art[0].name.as_deref(), Some("Photo"));
}
/// A 64 × 64 RGB image, every pixel of it different, 128 pt square at (20, 10) in `doc`.
fn speckled_photo(doc: &mut Document) -> Node {
let px = image::RgbImage::from_fn(64, 64, |x, y| image::Rgb([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8]));
image_node(doc, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None)
}
/// The sizes `%%BeginData:` gives the images' samples in the program written from `doc`.
fn sample_sizes(doc: &Document) -> Vec<usize> {
let program = export_ai_editing(doc, &opts()).unwrap().program;
String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%%BeginData: ")?.trim().parse().ok()).collect()
}
/// The samples of a 64 × 64 RGB image, as `%%BeginData:` counts them (`XI` and CR LF too).
const SPECKLED_WHOLE: usize = 64 * 64 * 3 + 4;
/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp or an
/// envelope on an image) are written as the image whole once, then each as the part of it that its
/// triangle shows: the file holds a little more than the image's pixels rather than a copy for every
/// piece, and it reads back drawing as it did.
#[test]
fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() {
// GUARDS: each of a warped image's 200 pieces was written with all of the image's samples.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let NodeKind::Image(im) = &photo.kind else { panic!("not an image") };
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 10, false, false)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 200, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image");
// Each piece holds its cell (two triangles share one) and 2 pixels more around it.
let total: usize = sizes.iter().sum();
assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// The pieces of a warped image that Preserve Shape Using Transparency cuts with opacity masks that
/// clip are written as the parts their masks show, and the file reads back drawing as it did.
#[test]
fn masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show() {
// GUARDS: each masked piece of a warped image was written with all of the image's samples.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let NodeKind::Image(im) = &photo.kind else { panic!("not an image") };
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 32, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image");
let total: usize = sizes.iter().sum();
assert!(total < 6 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// An image with a live Warp keeps its pixels whole once, and its drawn look's pieces are each the
/// part of them they show: it reads back as the image with its Warp live.
#[test]
fn an_image_with_a_live_warp_writes_its_pixels_whole_once() {
// GUARDS: a live Warp's drawn look wrote all of the image's samples for each of its pieces.
let doc = photo_doc(|d| {
let mut photo = speckled_photo(d);
photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into();
photo
});
let sizes = sample_sizes(&doc);
assert!(sizes.len() > 100, "{sizes:?}");
assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "{sizes:?}");
let total: usize = sizes.iter().sum();
assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
let back = read_back(&doc);
let art = back.layers[0].children().unwrap();
assert_eq!(art.len(), 1, "{:#?}", lines(&back));
assert!(matches!(art[0].kind, NodeKind::Image(_)), "{:?}", art[0].kind);
assert_eq!(art[0].appearance.effects.len(), 1, "{:?}", art[0].appearance);
}
/// An image cut to a clipping path is written with all of its pixels when it is the first of them
/// written, so releasing the clipping path shows the whole image; a second copy cut to a small part
/// is written as that part, 2 pixels more on each side.
#[test]
fn a_clipped_image_written_first_keeps_all_of_its_pixels() {
let window = |d: &mut Document| {
let photo = speckled_photo(d);
let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)));
group(d, vec![corner, photo], true)
};
let doc = photo_doc(|d| {
let (a, b) = (window(d), window(d));
group(d, vec![a, b], false)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 2, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE);
// The path shows 10 × 10 pixels.
assert_eq!(sizes[1], 14 * 14 * 3 + 4, "{sizes:?}");
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.002 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// The images in `n` and its members, each as its count of effects, its width and its height.
fn images_in(n: &Node, out: &mut Vec<(usize, u32, u32)>) {
if let NodeKind::Image(im) = &n.kind {
out.push((n.appearance.effects.len(), im.width, im.height));
}
for c in n.children().into_iter().flatten() {
images_in(c, out);
}
}
/// A copy of an image written whole already that its clipping path shows none of is written as one
/// pixel of it.
#[test]
fn a_copy_of_an_image_that_does_not_show_is_one_pixel() {
// GUARDS: a copy whose clipping path missed the image was written with all of its pixels.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)));
let first = group(d, vec![corner, photo], true);
let photo = speckled_photo(d);
let away = clipping(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 5.0)));
let second = group(d, vec![away, photo], true);
group(d, vec![first, second], false)
});
assert_eq!(sample_sizes(&doc), [SPECKLED_WHOLE, 3 + 4]);
}
/// A 200 × 100 document of: the speckled image when `first`; a square whose opacity mask's art is
/// the image then six squares; the image in a small clipping group.
fn mask_art_then_clipped_photo(first: bool) -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
if first {
let photo = speckled_photo(&mut doc);
doc.insert(Some(layer), usize::MAX, photo).unwrap();
}
let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5));
// The image, then more objects than the clipping group after the mask holds.
let mut members = vec![speckled_photo(&mut doc)];
for i in 0..6 {
let x = f64::from(i) * 10.0;
members.push(square(&mut doc, Rect::new(x, 0.0, x + 5.0, 5.0), Color::rgb(1.0, 1.0, 1.0)));
}
let art = group(&mut doc, members, false);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
doc.insert(Some(layer), usize::MAX, masked).unwrap();
let photo = speckled_photo(&mut doc);
let corner = clipping(&mut doc, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)));
let clipped = group(&mut doc, vec![corner, photo], true);
doc.insert(Some(layer), usize::MAX, clipped).unwrap();
doc
}
/// For each object limit from none to all of `doc`'s objects: whether the mask is written, and the
/// sizes `%%BeginData:` gives the images' samples on `%_` lines (the mask's art) and on the others.
fn samples_within_each_limit(doc: &Document) -> Vec<(usize, bool, Vec<usize>, Vec<usize>)> {
let read = crate::import::objects_read(&export_ai_editing(doc, &opts()).unwrap().program).unwrap();
(0..=read)
.map(|max| {
let text = String::from_utf8_lossy(&export_within(doc, &opts(), max).unwrap().program).replace("\r\n", "\n");
let size = |l: &str| l.strip_prefix("%%BeginData: ")?.trim().parse::<usize>().ok();
let hidden = text.lines().filter_map(|l| size(l.strip_prefix("%_")?)).collect();
let shown = text.lines().filter_map(size).collect();
(max, text.contains("(AI9_Mask)"), hidden, shown)
})
.collect()
}
/// An image whose whole copy was in art left out for the object limit (here an opacity mask's art)
/// is written whole again where it is next written, even cut to a small clipping path: the file
/// always holds all of its pixels once.
#[test]
fn an_image_whose_whole_copy_is_left_out_is_written_whole_again() {
// GUARDS: once the mask's art was left out, the clipped copy was written as the part it shows.
let mut left_out_with_copy = 0;
for (max, mask, hidden, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(false)) {
if hidden.is_empty() && shown.is_empty() {
continue;
}
assert!(hidden.contains(&SPECKLED_WHOLE) || shown.contains(&SPECKLED_WHOLE), "room for {max}: {hidden:?} {shown:?}");
left_out_with_copy += usize::from(!mask);
}
assert!(left_out_with_copy > 0, "no room left the mask out and kept the clipped copy");
}
/// An image written whole, then whole again in an opacity mask's art left out for the object limit,
/// is not written whole a third time: the first copy is kept, and the clipped copy after the mask is
/// the part it shows.
#[test]
fn an_image_written_whole_before_art_left_out_stays_written_whole() {
// GUARDS: the mask's copy took the first copy's place, so giving it back wrote the clipped copy whole.
let mut left_out = 0;
for (max, mask, _, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(true)) {
assert!(shown.iter().filter(|s| **s == SPECKLED_WHOLE).count() <= 1, "room for {max}: {shown:?}");
left_out += usize::from(!mask && shown.len() == 2);
}
assert!(left_out > 0, "no room left the mask out and kept both copies");
}
/// An image with a live Warp in a small clipping group keeps all of its pixels: its drawn look is
/// written first, and neither the look nor the image itself is cut to the clipping path, so it
/// reads back with its Warp live and every pixel it had.
#[test]
fn a_clipped_image_with_a_live_warp_keeps_all_of_its_pixels() {
// GUARDS: the image under a live Warp was cut to the clipping path around it.
let doc = photo_doc(|d| {
let mut photo = speckled_photo(d);
photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into();
let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 60.0, 50.0)));
group(d, vec![corner, photo], true)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "the look's first piece: {sizes:?}");
let total: usize = sizes.iter().sum();
assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
// The image itself, on `%_` lines.
let program = export_ai_editing(&doc, &opts()).unwrap().program;
let hidden: Vec<usize> =
String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%_%%BeginData: ")?.trim().parse().ok()).collect();
assert_eq!(hidden, [SPECKLED_WHOLE]);
let back = read_back(&doc);
let mut images = vec![];
for n in back.layers[0].children().unwrap() {
images_in(n, &mut images);
}
assert_eq!(images, [(1, 64, 64)], "{:#?}", lines(&back));
}
/// An image cut by an opacity mask whose art has an effect that paints past its path keeps the
/// pixels the effect shows: a copy of an image written whole already is cut to all that its mask
/// paints.
#[test]
fn a_masked_image_keeps_the_pixels_its_mask_effects_show() {
// GUARDS: the copy was cut to the mask art's path, short of what its Offset Path paints.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let corner = clipping(d, shapes::rectangle(Rect::new(20.0, 10.0, 24.0, 14.0)));
let first = group(d, vec![corner, photo], true);
let mut masked = speckled_photo(d);
let mut ap = Appearance::basic(solid(Color::WHITE), Paint::None, 0.0);
ap.effects = vec![fx("path.offsetPath", serde_json::json!({"offset": 30.0}))];
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 40.0, 80.0, 50.0)), ap);
masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }));
group(d, vec![first, masked], false)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 2, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE);
// The offset art covers (40, 10) to (110, 80): 35 × 35 pixels of the image.
assert!(sizes[1] >= 35 * 35 * 3, "{sizes:?}");
}
/// 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.
#[test]
+41 -3
View File
@@ -270,6 +270,15 @@ pub(super) struct Writer<'a> {
/// How many more envelopes may be written live: as many as the reader checks in one file
/// ([`crate::import::ENVELOPE_CHECKS`]).
envelopes_left: usize,
/// The image read last and its pixels ([`images::Samples::of`], `None` when they can't be
/// read): the pieces of a warped image, which follow one another, read them once.
last_samples: Option<(vectorcraft_doc::ImageBlob, Option<images::Read>)>,
/// The images written whole so far, by the address of their data (which each entry keeps), each
/// with the index of its samples in [`Self::payloads`].
whole_images: HashMap<usize, (Arc<Vec<u8>>, usize)>,
/// The outlines of the clipping groups the art being written is in, in the document (empty in
/// a definition, an opacity mask's art and a brush's art, which are written apart from them).
shown: Vec<vectorcraft_geom::BezPath>,
}
impl<'a> Writer<'a> {
@@ -334,6 +343,9 @@ impl<'a> Writer<'a> {
definition_objects: 0,
definition_symbol_nodes: 0,
max_objects: crate::import::MAX_EDITING_OBJECTS,
last_samples: None,
whole_images: HashMap::new(),
shown: vec![],
}
}
@@ -400,15 +412,21 @@ impl<'a> Writer<'a> {
/// Art written since `c` as `lines` is not used: the objects and symbol art charged since are
/// given back, except those of the definitions written since, and the images `lines` marks
/// are emptied (the others keep their indices).
/// are emptied (the others keep their indices). An image whose whole copy is among them is no
/// longer written whole ([`Self::whole_images`]).
fn give_back(&mut self, c: Charges, lines: &str) {
self.objects = c.objects + (self.definition_objects - c.kept_objects);
self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes);
let mut emptied = HashSet::new();
for index in lines.split(PAYLOAD).skip(1).step_by(2) {
if let Some(p) = index.parse::<usize>().ok().and_then(|i| self.payloads.get_mut(i)) {
if let Some((i, p)) = index.parse::<usize>().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) {
*p = Vec::new();
emptied.insert(i);
}
}
if !emptied.is_empty() {
self.whole_images.retain(|_, (_, i)| !emptied.contains(i));
}
}
/// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written
@@ -654,7 +672,23 @@ impl<'a> Writer<'a> {
}
NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy),
NodeKind::Text(_) => self.text(n, xy),
NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im), xy),
NodeKind::Image(im) => {
// An opacity mask that clips shows the image within what its art paints only (its
// effects and its symbol instances' art included, as the renderer measures it).
let masked = n
.extras()
.mask
.as_ref()
.filter(|m| m.clip && !m.invert && !m.disabled)
.and_then(|m| vectorcraft_render::painted_bounds(self.doc, &m.art));
if let Some(b) = masked {
self.shown.push(b.to_path(0.1));
}
self.image(&self.doc.shown_image(n, im), xy);
if masked.is_some() {
self.shown.pop();
}
}
NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy),
NodeKind::SymbolInstance { symbol, xf } => {
if !self.open_group("u") {
@@ -828,6 +862,7 @@ impl<'a> Writer<'a> {
fn mask(&mut self, m: &OpacityMask) -> bool {
let outer = std::mem::take(&mut self.body);
let (state, gs, clip) = (self.state, self.gs, self.clip.take());
let shown_by = std::mem::take(&mut self.shown);
let (charges, bound) = (self.charges(), self.bound.len());
(self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE));
// The canvas draws the art's root even when it is hidden, and the reader puts a hidden object
@@ -861,6 +896,7 @@ impl<'a> Writer<'a> {
self.hidden -= 1;
self.nest -= 1;
let lines = std::mem::replace(&mut self.body, outer);
self.shown = shown_by;
if self.left_out_since(charges) {
// The art isn't written, nor are the variable bindings of its objects.
self.give_back(charges, &lines);
@@ -946,9 +982,11 @@ impl<'a> Writer<'a> {
}
}
self.clip_paint_part(first, &path, rule, &fills);
self.shown.push(path.to_bezpath());
for c in rest {
self.node(c);
}
self.shown.pop();
self.clip_paint_part(first, &path, rule, &strokes);
self.state(first.locked, !first.visible);
let start = self.body.len();
+2 -2
View File
@@ -373,7 +373,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas),
self.state.take(),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.clip.take(), std::mem::take(&mut self.shown)),
(self.block_start, self.block_end),
// The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that
// first uses the brush.
@@ -410,7 +410,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas),
self.state,
self.gs,
self.clip,
(self.clip, self.shown),
(self.block_start, self.block_end),
(self.hidden, self.in_look, self.in_symbol),
) = saved;
+66 -7
View File
@@ -2,13 +2,14 @@
//! (see [`crate::ai_editing`] for the form).
use std::fmt::Write;
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{FillLayer, ImageBlob, ImageObject, LinkInfo};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape};
use super::{Writer, Xy};
use crate::ps;
use crate::{ai_ops, ps};
pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps";
pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps";
@@ -26,8 +27,11 @@ pub(in crate::ai_editing) const PAYLOAD: char = '\u{1}';
/// What starts the line of an image's samples: `XI` and its payload, then `%%EndData`.
pub(super) const SAMPLES_LINE: &str = "XI\u{1}";
/// An image's pixels as the data holds them, and whether it had 16 bits a channel ([`Samples::of`]).
pub(super) type Read = Arc<(Samples, bool)>;
/// An image's pixels as the data holds them.
struct Samples {
pub(super) struct Samples {
/// `/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`.
space: &'static str,
/// The image type of `XI` (§8.1): 1 gray, 3 RGB, 4 CMYK.
@@ -79,6 +83,19 @@ impl Samples {
Some((Samples { space, kind, width, height, color, alpha }, deep))
}
/// The pixels of `window` (`[x0, y0, x1, y1]`, within the image) alone.
fn window(&self, window: [u32; 4]) -> Samples {
let [x0, y0, x1, y1] = window;
Samples {
space: self.space,
kind: self.kind,
width: x1 - x0,
height: y1 - y0,
color: ai_ops::window_samples(&self.color, self.width, usize::from(self.kind), window),
alpha: self.alpha.as_ref().map(|a| ai_ops::window_samples(a, self.width, 1, window)),
}
}
/// `XI`'s fourth flag as the files users sent have it: 0 gray, 1 RGB, 2 CMYK, 16 more with an
/// alpha channel.
fn reserved(&self) -> u8 {
@@ -95,10 +112,13 @@ 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
/// cut to a clipping path, the image is in a clipping group of that path.
/// cut to a clipping path, the image is in a clipping group of that path; a raster of pixels
/// written whole already is then the part of them that the path shows ([`ai_ops::image_window`]).
pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) {
let doc = self.doc;
let Some((samples, deep)) = doc.image_blob(im).and_then(Samples::of) else {
let blob = doc.image_blob(im);
let read = blob.and_then(|b| self.samples(b));
let Some((samples, deep)) = read.as_deref() else {
let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1)));
self.warn(IMAGES);
return self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(Color::gray(0.5))), xy);
@@ -112,7 +132,7 @@ impl Writer<'_> {
if !self.take_objects(1 + clipping) {
return;
}
if deep {
if *deep {
self.warn(IMAGE_DEPTH);
}
let cut = self.clip.take();
@@ -128,13 +148,38 @@ impl Writer<'_> {
Some(link) if self.hidden == 0 && !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);
self.placed_file(link, samples, (im.width.max(1), im.height.max(1)), im.xf);
}
other => {
if other.is_some() {
self.warn(LINKS_EMBEDDED);
}
self.raster(&samples, im.xf * fit);
// An image written whole already: the part its region shows, placed where it is in
// the image (the pieces of a warped image each show a small part of the same one).
// An object's editable original (on `%_` lines) and an image in an opacity mask's art
// are written whole.
let data = blob.map(|b| &b.bytes);
let written = self.hidden == 0 && data.is_some_and(|d| self.whole_images.contains_key(&(Arc::as_ptr(d) as usize)));
let window = written
.then(|| {
let clips: Vec<_> = self.shown.iter().cloned().chain(cut.iter().map(PathData::to_bezpath)).collect();
ai_ops::image_window(&clips, im.xf * fit, (samples.width, samples.height))
})
.flatten();
match window {
Some(window) => {
self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1]))))
}
None => {
if let Some(d) = data {
// `raster` adds the samples as the next payload. A copy written whole on
// `%_` lines after an earlier whole copy keeps the earlier one's entry.
let next = self.payloads.len();
self.whole_images.entry(Arc::as_ptr(d) as usize).or_insert_with(|| (d.clone(), next));
}
self.raster(samples, im.xf * fit);
}
}
}
}
if let Some(region) = &cut {
@@ -231,6 +276,20 @@ impl Writer<'_> {
);
}
/// The pixels of `blob` ([`Samples::of`]), read once for images that follow one another with
/// the same data.
fn samples(&mut self, blob: &ImageBlob) -> Option<Read> {
if let Some((last, read)) = &self.last_samples
&& Arc::ptr_eq(&last.bytes, &blob.bytes)
&& last.mime == blob.mime
{
return read.clone();
}
let read = Samples::of(blob).map(Arc::new);
self.last_samples = Some((blob.clone(), read.clone()));
read
}
/// Keep `bytes` to go in the program where the mark this returns is.
fn payload(&mut self, bytes: Vec<u8>) -> String {
self.payloads.push(bytes);
+2 -2
View File
@@ -481,7 +481,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas),
self.state.take(),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.clip.take(), std::mem::take(&mut self.shown)),
self.block_end,
);
// `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its
@@ -505,7 +505,7 @@ impl Writer<'_> {
self.hidden -= 1;
self.nest -= 1;
let art;
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved;
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved;
let art = std::mem::replace(&mut self.body, art);
self.defining.pop();
if self.left_out_since(before) {
@@ -742,6 +742,9 @@ impl Writer<'_> {
let xy = Xy::of(orig);
let was = self.in_look;
self.in_look = true;
// The reader draws the look apart from the clipping groups around the object to compare it
// with the object's effects, and keeps the object itself: neither is cut to those groups.
let shown_by = std::mem::take(&mut self.shown);
self.line("u");
let mut look = n.clone();
(look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) =
@@ -785,6 +788,7 @@ impl Writer<'_> {
}
self.dictionary(orig);
self.hidden -= 1;
self.shown = shown_by;
let part = std::mem::replace(&mut self.body, saved);
hide(&part, &mut self.body);
self.block_end = usize::MAX;
+2 -2
View File
@@ -243,7 +243,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas, self.instance_space),
self.state.replace((false, false)),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.clip.take(), std::mem::take(&mut self.shown)),
(self.block_start, self.block_end),
(std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)),
self.root_entries.take(),
@@ -300,7 +300,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas, self.instance_space),
self.state,
self.gs,
self.clip,
(self.clip, self.shown),
(self.block_start, self.block_end),
(self.in_symbol, self.hidden),
self.root_entries,
+50 -1
View File
@@ -7,7 +7,7 @@ use std::fmt::Write;
use vectorcraft_color::{Color, Gradient};
use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Shape, SubPath};
use crate::ps;
@@ -131,6 +131,55 @@ pub(crate) fn average(g: &Gradient) -> Color {
Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n))
}
/// The pixels an image's part keeps past the edge of the region that shows it, on each side: as
/// many as smoothing reads from beside a pixel, and one more.
const WINDOW_MARGIN: f64 = 2.0;
/// The part of an image of `w` × `h` pixels, which `to_doc` places in the document, that regions
/// `clips` (each in the document, the image cut to all of them) show, with [`WINDOW_MARGIN`] pixels
/// more on each side: `[x0, y0, x1, y1]`, in pixels from the top left. One pixel of the image, the
/// nearest to where the regions are, when they show none of it. `None` when that part has half the
/// image's pixels or more, or when `to_doc` can't be undone; the whole image is written then.
pub(crate) fn image_window(clips: &[kurbo::BezPath], to_doc: Affine, (w, h): (u32, u32)) -> Option<[u32; 4]> {
if clips.is_empty() || w == 0 || h == 0 || !to_doc.is_finite() || to_doc.determinant().abs() < 1e-12 {
return None;
}
let to_pixels = to_doc.inverse();
let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, f64::from(w), f64::from(h));
for clip in clips {
let b = (to_pixels * clip.clone()).bounding_box();
if !b.is_finite() {
return None;
}
x0 = f64::max(x0, b.x0.floor() - WINDOW_MARGIN);
y0 = f64::max(y0, b.y0.floor() - WINDOW_MARGIN);
x1 = f64::min(x1, b.x1.ceil() + WINDOW_MARGIN);
y1 = f64::min(y1, b.y1.ceil() + WINDOW_MARGIN);
}
if x0 >= x1 || y0 >= y1 {
let (x, y) = ((x0 as u32).min(w - 1), (y0 as u32).min(h - 1));
return Some([x, y, x + 1, y + 1]);
}
if 2.0 * (x1 - x0) * (y1 - y0) >= f64::from(w) * f64::from(h) {
return None;
}
Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32])
}
/// The rows `y0..y1` and columns `x0..x1` (`window`) of `samples`, `w` pixels a row of `channels`
/// bytes each, rows from the top.
pub(crate) fn window_samples(samples: &[u8], w: u32, channels: usize, [x0, y0, x1, y1]: [u32; 4]) -> Vec<u8> {
let row = w as usize * channels;
let (from, to) = (x0 as usize * channels, x1 as usize * channels);
let mut out = Vec::with_capacity((y1 - y0) as usize * (to - from));
for y in y0 as usize..y1 as usize {
if let Some(part) = samples.get(y * row + from..y * row + to) {
out.extend_from_slice(part);
}
}
out
}
/// The stroke's width, cap, join, miter limit and dashes: `w J j M d`.
pub(crate) fn line_style(s: &mut String, st: &StrokeLayer) {
let cap = match st.cap {
+41
View File
@@ -658,6 +658,47 @@ fn images_are_written_in_the_form_the_specification_shows() {
assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}");
}
/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp on an
/// image) are written as the image whole once, then each as the part of it that its triangle shows,
/// and the file draws as the document does ([`write`] runs it).
#[test]
fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() {
// GUARDS: each of a warped image's 200 pieces was written with all of the image's samples.
let mut doc = Document::new(200.0, 160.0);
let mut png = vec![];
image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let blob = ImageBlob::new("image/png", png);
let key = blob.content_key();
doc.images.insert(key.clone(), blob);
let im = ImageObject {
key,
width: 64,
height: 64,
xf: Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]),
link: None,
placement: Default::default(),
pixels: None,
};
let photo = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im.clone())));
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
let warped = vectorcraft_doc::live::mesh_warp_image(&photo, &im, &bend, 10, false, false);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, warped).unwrap();
let text = String::from_utf8(write(&doc).bytes).unwrap();
let images = rasters(&text);
assert_eq!(images.len(), 200);
let digits = |lines: &Vec<&str>| {
lines.iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).map(str::len).sum::<usize>()
};
let whole = 64 * 64 * 3 * 2;
assert_eq!(digits(&images[0]), whole, "the first piece holds the whole image");
// Each piece holds its cell (two triangles share one) and 2 pixels more around it.
let total: usize = images.iter().map(digits).sum();
assert!(total < 10 * whole, "{total} digits of samples for an image of {whole}");
}
/// An image whose data is empty is left out with the note for empty or too large images, and an
/// image whose data can't be decoded is left out with the decode note.
#[test]
+52 -9
View File
@@ -45,6 +45,13 @@ pub(super) struct Writer<'a> {
roman: bool,
/// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type).
kumi: bool,
/// The outlines of the clipping groups the art being written is in, in the document.
clips: Vec<BezPath>,
/// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow
/// one another, decode it once.
last_image: Option<(ImageBlob, Option<Arc<image::DynamicImage>>)>,
/// The images written whole so far, by the address of their data (which each entry keeps).
whole_images: std::collections::HashMap<usize, Arc<Vec<u8>>>,
}
impl<'a> Writer<'a> {
@@ -68,6 +75,9 @@ impl<'a> Writer<'a> {
fonts: vec![],
roman: false,
kumi: false,
clips: vec![],
last_image: None,
whole_images: std::collections::HashMap::new(),
}
}
@@ -360,12 +370,14 @@ impl<'a> Writer<'a> {
if several {
self.line("*U");
}
self.clips.push(outline);
if let Some(fill) = &paint.fill {
self.node(fill, locked);
}
for c in rest {
self.node(c, locked);
}
self.clips.pop();
self.lock(locked);
self.line("Q");
if let Some(stroke) = &paint.stroke {
@@ -992,7 +1004,8 @@ impl<'a> Writer<'a> {
/// whose pixels are all black or white is written as an image mask (ImageMask 1, §8.1): its
/// color as the fill, then 1-bit samples, a 0 bit for each black pixel, which takes the color,
/// and a 1 bit for each white pixel, which is clear. Another colorized image is written with
/// its color in its pixels.
/// its color in its pixels. In a clipping group, an image whose pixels were written whole
/// already is written as the part of them the group shows ([`ai_ops::image_window`]).
fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, colorize: Option<&Paint>) {
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let m = self.to_art * xf * flip;
@@ -1003,7 +1016,7 @@ impl<'a> Writer<'a> {
self.warn(LARGE_IMAGE);
return;
}
let Some(img) = blob.and_then(|b| image::load_from_memory(&b.bytes).ok()) else {
let Some((blob, img)) = blob.and_then(|b| Some((b, self.decoded(b)?))) else {
self.warn(UNDECODED);
return;
};
@@ -1014,14 +1027,31 @@ impl<'a> Writer<'a> {
}
// The object places the image's own pixel grid (its width × height) wherever the file's is.
let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h));
let m = self.to_art * xf * fit * flip;
let coverage = match colorize {
Some(paint) => {
let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else {
self.warn(UNDECODED);
return;
};
Some((paint, coverage))
}
None => None,
};
// An image written whole already, cut to a clipping group: the part the group shows, placed
// where it is in the image (the pieces of a warped image each show a small part of the same one).
let key = Arc::as_ptr(&blob.bytes) as usize;
let window = if self.whole_images.contains_key(&key) { ai_ops::image_window(&self.clips, xf * fit, (w, h)) } else { None };
if window.is_none() {
self.whole_images.insert(key, blob.bytes.clone());
}
let window = window.unwrap_or([0, 0, w, h]);
let [x0, y0, x1, y1] = window;
let m = self.to_art * xf * fit * Affine::translate((f64::from(x0), f64::from(y0))) * flip;
let (w, h) = (x1 - x0, y1 - y0);
// Over white.
let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8;
if let Some(paint) = colorize {
let Some((_, _, coverage)) = blob.and_then(ImageBlob::coverage).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else {
self.warn(UNDECODED);
return;
};
if let Some((paint, coverage)) = coverage {
let coverage = ai_ops::window_samples(&coverage, img.width(), 1, window);
if coverage.iter().all(|&v| v == 0 || v == 255) {
let stride = (w as usize).div_ceil(8);
let mut bits = vec![0xFF; stride.saturating_mul(h as usize)];
@@ -1041,7 +1071,7 @@ impl<'a> Writer<'a> {
self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples);
return;
}
let rgba = img.to_rgba8();
let rgba = img.crop_imm(x0, y0, w, h).to_rgba8();
let mut samples = Vec::with_capacity(rgba.len() / 4 * 3);
let mut see_through = false;
for px in rgba.pixels() {
@@ -1057,6 +1087,19 @@ impl<'a> Writer<'a> {
self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples);
}
/// The pixels of `blob`, decoded once for images that follow one another with the same data.
fn decoded(&mut self, blob: &ImageBlob) -> Option<Arc<image::DynamicImage>> {
if let Some((last, img)) = &self.last_image
&& Arc::ptr_eq(&last.bytes, &blob.bytes)
&& last.mime == blob.mime
{
return img.clone();
}
let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new);
self.last_image = Some((blob.clone(), img.clone()));
img
}
/// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]`
/// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`]
/// digits, `%AI5_EndRaster`, then `F`.
+1
View File
@@ -616,6 +616,7 @@ since 2026-10-01.
- **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.
- **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.
- **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now add about twice the image's pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). 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.
- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests.