Merge pull request #7285 from dotCooCoo/fix/pdf-warped-image-written-once

PDF export: a warped image writes its pixels once for all its pieces
This commit is contained in:
BFlat
2026-11-14 05:39:34 +07:00
committed by GitHub
8 changed files with 204 additions and 12 deletions
+3
View File
@@ -60,6 +60,9 @@ pub(crate) struct Exporter<'a> {
images: HashMap<String, Option<Image>>,
/// `Some` while opacity mask art is written: whether an image in it was left out.
image_left_out: Option<bool>,
/// While the pieces of a mesh-warped image are written: the size its pixels are resampled for
/// ([`Exporter::warp_image_size`]).
warp_size: Option<images::SharedSize>,
/// The brush library, parsed when the first brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// Whether the group being written is a knockout group (what its neutral children inherit).
+1
View File
@@ -31,6 +31,7 @@ impl<'a> Exporter<'a> {
warnings: vec![],
images: HashMap::new(),
image_left_out: None,
warp_size: None,
brushes: None,
knockout: doc.page_knockout,
lab_spots: vec![],
+101 -10
View File
@@ -1,5 +1,5 @@
use super::exporter::{UNDECODED, UNWRITABLE_IMAGE, q, writable, xf};
use super::{Arc, BlendMode, Color, Exporter, Image, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla};
use super::{Arc, BlendMode, Color, Exporter, Image, Node, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla};
use krilla::color::device_n::{Colorant, DeviceNSpace, Process};
use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace};
@@ -59,13 +59,17 @@ impl Exporter<'_> {
/// the Output settings write them. The coverage is resampled as the Compression settings ask
/// ([`crate::images::coverage_sized`]). `None` when the paint isn't a solid color, the image is
/// missing or left out, its pixels can't be decoded, or its color channel exceeds
/// [`vectorcraft_doc::MAX_RASTER_ALLOC`]; image rejections are cached like [`Self::load_image`].
/// [`vectorcraft_doc::MAX_RASTER_ALLOC`]; cached by key and paint (and size when images are
/// resampled), image rejections too.
/// With `interpolate` (and the export's own setting), viewers may smooth it.
pub(super) fn colorized_image(&mut self, im: &vectorcraft_doc::ImageObject, paint: &Paint, interpolate: bool) -> Option<Image> {
let Paint::Solid { color, swatch, tint } = paint else { return None };
let size = placed_size(im);
let size = self.image_size(im);
let interpolate = interpolate && self.interpolate;
let mut cache = format!("{}\u{0}{paint:?}\u{0}{:.3}x{:.3}", im.key, size.0, size.1);
let mut cache = format!("{}\u{0}{paint:?}", im.key);
if self.downsampled() {
cache.push_str(&format!("\u{0}{:.3}x{:.3}", size.0, size.1));
}
if !interpolate && self.interpolate {
cache.push_str("\u{0}pixelated");
}
@@ -124,9 +128,8 @@ impl Exporter<'_> {
fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64), interpolate: bool) -> Option<Image> {
let key = im.key.as_str();
let c = self.compression;
let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None);
let interpolate = interpolate && self.interpolate;
let mut cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() };
let mut cache = if self.downsampled() { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() };
if !interpolate && self.interpolate {
cache.push_str("\u{0}pixelated");
}
@@ -173,9 +176,12 @@ impl Exporter<'_> {
if m.invert || !plain || (mi.xf, mi.width, mi.height) != (im.xf, im.width, im.height) {
return None;
}
let size = placed_size(im);
let size = self.image_size(im);
let interpolate = interpolate && self.interpolate;
let mut cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1);
let mut cache = format!("{}\u{0}{}\u{0}{}", im.key, mi.key, m.clip);
if self.downsampled() {
cache.push_str(&format!("\u{0}{:.3}x{:.3}", size.0, size.1));
}
if !interpolate && self.interpolate {
cache.push_str("\u{0}pixelated");
}
@@ -206,7 +212,7 @@ impl Exporter<'_> {
}
return;
}
let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else {
let Some(img) = img.or_else(|| self.load_image(im, self.image_size(im), !pixelated)) else {
self.image_left_out = self.image_left_out.map(|_| true);
return;
};
@@ -221,6 +227,56 @@ impl Exporter<'_> {
s.draw_image(img, size);
s.pop();
}
/// Whether the Compression settings resample any kind of image.
fn downsampled(&self) -> bool {
let c = self.compression;
[c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None)
}
/// The size image `im` is resampled for: [`Self::warp_image_size`]'s while the pieces of a
/// mesh-warped image of the same pixels are written, else the size it is placed at.
fn image_size(&self, im: &vectorcraft_doc::ImageObject) -> (f64, f64) {
match &self.warp_size {
Some(w) if (w.key.as_str(), w.width, w.height) == (im.key.as_str(), im.width, im.height) => w.size,
_ => placed_size(im),
}
}
/// When `children` are the pieces of a mesh-warped image ([`Node::warp_piece_image`]) and the
/// Compression settings resample images: the image they show, and the widest and highest any
/// piece places it, which every piece's image is resampled for, so that the pieces paint one
/// image. `None` for other children, pieces of different images, or a size that isn't finite
/// and positive.
pub(super) fn warp_image_size(&self, children: &[Arc<Node>]) -> Option<SharedSize> {
if !self.downsampled() {
return None;
}
let mut shared: Option<SharedSize> = None;
for c in children {
let n = c.warp_piece_image()?;
let NodeKind::Image(im) = &n.kind else { return None };
let im = self.doc.shown_image(n, im);
let (w, h) = placed_size(&im);
match &mut shared {
Some(s) if (s.key.as_str(), s.width, s.height) == (im.key.as_str(), im.width, im.height) => {
s.size = (s.size.0.max(w), s.size.1.max(h));
}
Some(_) => return None,
None => shared = Some(SharedSize { key: im.key.clone(), width: im.width, height: im.height, size: (w, h) }),
}
}
shared.filter(|s| s.size.0.is_finite() && s.size.1.is_finite() && s.size.0 > 0.0 && s.size.1 > 0.0)
}
}
/// The size the pixels of image `key` (`width` × `height` pixels) are resampled for while the
/// pieces of a mesh-warped image are written ([`Exporter::warp_image_size`]).
pub(super) struct SharedSize {
key: String,
width: u32,
height: u32,
size: (f64, f64),
}
/// The size image `im` is placed at: its pixel grid's sides through its transform.
@@ -320,13 +376,48 @@ mod tests {
let image =
ImageObject { key: key.into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
assert!(exporter.colorized_image(&image, &paint, true).is_none(), "{key}");
let cache = format!("{key}\u{0}{paint:?}\u{0}1.000x1.000");
let cache = format!("{key}\u{0}{paint:?}");
assert!(matches!(exporter.images.get(&cache), Some(None)), "rejection for {key} was not cached");
assert!(exporter.colorized_image(&image, &paint, true).is_none(), "second call for {key}");
}
assert_eq!(exporter.images.len(), 3);
}
/// Without downsampling, a colorized image and a CMYK image with a grid mask are made once for
/// every size they are placed at.
#[test]
fn colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling() {
let gray = |pixels: Vec<u8>| {
let mut png = vec![];
image::GrayImage::from_raw(2, 2, pixels).unwrap().write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
ImageBlob::png(png)
};
let mut doc = Document::new(100.0, 100.0);
doc.images.insert("pic".into(), gray(vec![0, 255, 128, 64]));
doc.images.insert("alpha".into(), gray(vec![255, 128, 0, 64]));
let inks = vectorcraft_doc::cmyk::Inks::new(2, 2, [0u8, 102, 255, 0].repeat(4)).unwrap();
doc.images.insert("inks".into(), ImageBlob::cmyk_tiff(&inks).unwrap());
let settings = crate::PdfSettings::default();
let mut exporter = Exporter::new(&doc, &settings);
let paint = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let image = |key: &str, scale: f64| ImageObject {
key: key.into(),
width: 2,
height: 2,
xf: Affine::scale(scale),
link: None,
placement: Default::default(),
pixels: None,
};
for scale in [10.0, 20.0] {
assert!(exporter.colorized_image(&image("pic", scale), &paint, true).is_some(), "colorized at {scale}");
let art = vectorcraft_doc::Node::new(doc.layers[0].id, vectorcraft_doc::NodeKind::Image(Box::new(image("alpha", scale))));
let mask = vectorcraft_doc::OpacityMask::new(art, true);
assert!(exporter.masked_image(&image("inks", scale), &mask, true).is_some(), "masked at {scale}");
}
assert_eq!(exporter.images.len(), 2, "{:?}", exporter.images.keys().collect::<Vec<_>>());
}
#[test]
fn tinted_color_rejects_oversized_and_overflowing_buffers() {
let max = usize::try_from(vectorcraft_doc::MAX_RASTER_ALLOC).unwrap_or(usize::MAX);
+12 -1
View File
@@ -100,6 +100,17 @@ impl Exporter<'_> {
}
}
/// [`Self::measured_children`] of a plain group or layer. When they are the pieces of a
/// mesh-warped image, every piece, on the page or not, takes part in the one size its pixels
/// are resampled for ([`Self::warp_image_size`]).
fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) {
let outer = self.warp_image_size(children).map(|w| self.warp_size.replace(w));
self.measured_children(s, children, page, measured);
if let Some(outer) = outer {
self.warp_size = outer;
}
}
/// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white,
/// then black through an alpha mask of `c` (at full object opacity without its own mask, unless
/// those define its shape).
@@ -268,7 +279,7 @@ impl Exporter<'_> {
}
match &n.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
}
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// The region every output clips to; nothing to clip by hides the clipped art. The
+33
View File
@@ -133,6 +133,39 @@ fn colorized_images_are_downsampled() {
assert!(text.contains("/Width 150") && !text.contains("/Width 600"), "resampled to 150 ppi");
}
/// The pieces of a colorized image under a mesh warp, placed at different widths, paint one
/// colorized image and its one soft mask, with downsampling off and on.
#[test]
fn a_colorized_warped_image_is_written_once_for_all_its_pieces() {
for compression in [serde_json::json!({}), serde_json::json!({"gray": {"downsample": "bicubic", "ppi": 150}})] {
let mut d = Document::new(100.0, 100.0);
let gray = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([((x + y) % 200) as u8]));
let mut png = vec![];
gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("pic".into(), ImageBlob::png(png));
let im = ImageObject {
key: "pic".into(),
width: 600,
height: 600,
xf: Affine::scale(72.0 / 600.0),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone())));
n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
// 0.5 to 1.5 times as wide from left to right.
let stretch = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x * (0.5 + p.x / 144.0), p.y);
let warped = vectorcraft_doc::live::mesh_warp_image(&n, &im, &stretch, 4, false, false);
d.insert(d.default_layer(), 0, warped).unwrap();
let settings: PdfSettings = serde_json::from_value(serde_json::json!({ "compression": compression })).unwrap();
let out = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
let found = images(&out);
assert_eq!(found.len(), 2, "{compression}: the colorized image and its soft mask, not {found:?}");
assert!(found.contains(&("DeviceRGB".into(), vec![], true)), "{compression}: {found:?}");
}
}
/// Printed separations keep a colorized image's pixels on every plate and separate its color:
/// a spot-colorized image prints on its ink's plate only. An uncolorized image of the same pixels
/// is separated as before.
+51 -1
View File
@@ -5,10 +5,11 @@
use std::io::Cursor;
use craft_pdf::syntax::object::{Name, Object};
use image::{DynamicImage, ImageFormat};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_geom::{Affine, Point};
use crate::*;
@@ -72,6 +73,55 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() {
assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi");
}
/// The width and height of each image XObject in `pdf`.
fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> {
let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap();
file.objects()
.into_iter()
.filter_map(|o| match o {
Object::Stream(s) if s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => {
Some((s.dict().get::<u32>(b"Width").unwrap_or(0), s.dict().get::<u32>(b"Height").unwrap_or(0)))
}
_ => None,
})
.collect()
}
/// A mesh warp that stretches the image across more toward its right side cuts it into pieces
/// placed at different widths. With downsampling on, every piece paints one image, resampled for
/// the widest piece; another placement of the image keeps its own resampling.
#[test]
fn a_warped_image_is_written_once_for_all_its_pieces() {
let compression = json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}});
for masked in [false, true] {
let mut d = doc(encoded(colour(), ImageFormat::Png), "image/png");
let id = d.layers[0].children().unwrap()[0].id;
let n = d.node(id).unwrap().clone();
let NodeKind::Image(im) = &n.kind else { panic!("the image") };
// 0.5 to 1.5 times as wide from left to right, within the page.
let stretch = |p: Point| Point::new(p.x * (0.5 + p.x / 144.0), p.y);
*d.node_mut(id).unwrap() = vectorcraft_doc::live::mesh_warp_image(&n, im, &stretch, 4, false, masked);
let mut settings: PdfSettings = serde_json::from_value(json!({ "compression": compression })).unwrap();
settings.compression.compress_text = false;
let bytes = export_with_report(&d, &PdfOptions { settings: settings.clone(), ..Default::default() }).unwrap().bytes;
let sizes = image_sizes(&bytes);
assert_eq!(sizes.len(), 1, "masked {masked}: one image for 32 pieces, not {sizes:?}");
// One inch high (150 pixels at 150 ppi), and wider than one inch at its widest.
let (w, h) = sizes[0];
assert!(h == 150 && w > 150 && w < 600, "masked {masked}: {w} × {h}");
assert!(String::from_utf8_lossy(&bytes).matches(" Do").count() >= 32, "masked {masked}: every piece paints the image");
// The image placed again, two inches wide and high, is resampled for that placement.
let plain = Node::new(d.alloc_id(), NodeKind::Image(Box::new(ImageObject { xf: Affine::scale(144.0 / 600.0), ..(**im).clone() })));
let l = d.layers[0].id;
d.insert(Some(l), 1, plain).unwrap();
let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
let mut sizes = image_sizes(&bytes);
sizes.sort_unstable();
assert_eq!(sizes, [(w, h), (300, 300)], "masked {masked}");
}
}
#[test]
fn jpeg_zip_and_automatic_compression() {
let png = doc(encoded(colour(), ImageFormat::Png), "image/png");
+2
View File
@@ -1065,6 +1065,8 @@ Images follow the `compression` settings of their kind (`color`, `gray`, or `mon
`abovePpi` as placed they are resampled (`downsample`: `average`, `subsample` or `bicubic`) to `ppi`, and compressed
with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others
lossless). `none`, `jpeg2000`, CCITT and `runLength` are written as ZIP, with a warning when an image needs them.
The pieces of an image bent by a Warp effect are resampled once, for the widest and the highest size any piece places
the image at, and all paint that one image.
A 16-bit PNG that is not resampled, recompressed or converted keeps its 16 bits a sample in PDF 1.5 and later files,
and is written at 8 bits in PDF 1.3 and 1.4 files.
CMYK images stay CMYK (DeviceCMYK, or ICC-based with the CMYK profile when colours are tagged, as in PDF/X-3 and
+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.
- **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.
- **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.