Merge pull request #7142 from dotCooCoo/feat/ai-image-warp-live

.ai: an image's Warp effect is saved and opened live
This commit is contained in:
BFlat
2026-11-13 23:57:33 +07:00
committed by GitHub
7 changed files with 311 additions and 23 deletions
+118
View File
@@ -7110,6 +7110,124 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
assert!(p.contains("7.5 /Real (moveV_Pts) ,") && p.contains("0.5236 /Real (rotate_Radians) ,"), "{p}");
}
/// An embedded 8 × 8 image of a color ramp, `Photo`, 80 pt square at (20, 10) in `doc`, at half
/// opacity, with `effects`.
fn warped_photo(doc: &mut Document, effects: Vec<vectorcraft_doc::Effect>) -> Node {
let ramp = image::RgbImage::from_fn(8, 8, |x, y| image::Rgb([(x * 32) as u8, (y * 32) as u8, 160]));
let mut photo = image_node(doc, ImageBlob::png(png_of(ramp.into())), (8, 8), Affine::new([10.0, 0.0, 0.0, 10.0, 20.0, 10.0]), None);
photo.name = Some("Photo".into());
photo.opacity = 0.5;
photo.appearance = Appearance { effects, ..Appearance::default() }.into();
photo
}
/// A 200 × 100 document holding `photo`, made by [`warped_photo`].
fn photo_doc(photo: impl FnOnce(&mut Document) -> Node) -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let n = photo(&mut doc);
doc.insert(Some(layer), usize::MAX, n).unwrap();
doc
}
/// An embedded image with one Warp is written with an art style of that Warp, its bent pieces as its
/// drawn look and the image itself on `%_` lines: it reads back as the image, its pixels, placement,
/// name and opacity as they were, with its Warp live and its settings kept.
#[test]
fn an_image_with_a_warp_reads_back_with_its_warp_live() {
// GUARDS: an image with a Warp was written as its drawn pieces alone, and read back without its Warp.
for (id, params) in [
("warp.arc", serde_json::json!({"bend": 40.0})),
("warp.flag", serde_json::json!({"bend": -30.0, "horizontal": 10.0, "vertical": 0.0, "orientation": "vertical"})),
] {
let doc = photo_doc(|d| warped_photo(d, vec![fx(id, params.clone())]));
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{id}: {:?}", out.warnings);
let back = read_back(&doc);
let art = back.layers[0].children().unwrap();
assert_eq!(art.len(), 1, "{id}: {:#?} {:?}", lines(&back), out.warnings);
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, "{id}");
let [warp] = art[0].appearance.effects.as_slice() else { panic!("{id}: {:?}", art[0].appearance) };
assert_eq!(warp.id, id);
let (w, g) = (vectorcraft_effects::merged_params(id, &params), 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, "{id} {key}: {g} for {w}");
}
assert_eq!(g["orientation"], w["orientation"], "{id}");
}
}
/// An image whose style's Warp bends it another way than its drawn look keeps that look, with the
/// note, and the file as written opens with the image.
#[test]
fn an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look() {
// GUARDS: an image read with its Warp live whatever its drawn look showed.
let doc = photo_doc(|d| warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]));
let program = export_ai_editing(&doc, &opts()).unwrap().program;
let (from, to) = (b"0.4 /Real (DeformValue)".as_slice(), b"-0.4 /Real (DeformValue)".as_slice());
let at = program.windows(from.len()).position(|w| w == from).expect("the warp's bend");
let mut other = program.clone();
other.splice(at..at + from.len(), to.iter().copied());
let (back, warnings) = crate::ai_alone(&other).unwrap();
let art = back.layers[0].children().unwrap();
assert_eq!(art.len(), 1, "{art:#?}");
assert!(matches!(art[0].kind, NodeKind::Group { .. }), "the drawn look: {:?}", art[0].kind);
assert!(art[0].appearance.effects.is_empty(), "{:?}", art[0].appearance);
assert!(warnings.iter().any(|w| w.contains("drawn look")), "{warnings:?}");
let (back, _) = crate::ai_alone(&program).unwrap();
assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Image(_)));
}
/// An image on `%_` lines is the copy of a styled object, as other objects there are: with a style
/// the file doesn't define, its drawn look stands for it, named as it is, and the image doesn't come
/// in beside the look.
#[test]
fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() {
// GUARDS: an image whose samples were on `%_` lines came in as a second, visible object.
let doc = photo_doc(|d| warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]));
let program = export_ai_editing(&doc, &opts()).unwrap().program;
let (from, to) = (b"1 (Anon 1) XW".as_slice(), b"1 (Unknown) XW".as_slice());
let at = program.windows(from.len()).position(|w| w == from).expect("the image's style");
let mut unknown = program.clone();
unknown.splice(at..at + from.len(), to.iter().copied());
let (back, _) = crate::ai_alone(&unknown).unwrap();
let art = back.layers[0].children().unwrap();
assert_eq!(art.len(), 1, "{:#?}", lines(&back));
assert!(matches!(art[0].kind, NodeKind::Group { .. }), "the drawn look: {:?}", art[0].kind);
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.
#[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"));
}
n
});
let masked = photo_doc(|d| {
let mut n = 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 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)] {
assert!(!program_shown(&doc).contains("1 (Anon"), "{what}");
let back = read_back(&doc);
let art = back.layers[0].children().unwrap();
assert!(art.iter().all(|n| n.appearance.effects.is_empty()), "{what}: {art:#?}");
}
}
/// Every kind of appearance a style isn't written for is written as its drawn look, with a warning.
#[test]
fn appearances_written_as_their_drawn_look() {
+27 -8
View File
@@ -41,7 +41,8 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]),
/// by id: 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, without Opacity & Mask Define
/// style or (paths and compound paths) with a brush on a stroke, and embedded 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
/// groups, none with effects or anything baking changes.
@@ -86,10 +87,22 @@ fn candidate(n: &Node) -> bool {
NodeKind::Path { path, guide: false, clipping: false, .. } => has_points(path),
NodeKind::Compound { .. } => has_points(&compound_path(n)),
NodeKind::Group { children, clip: false } => !n.is_compound_shape() && children.iter().all(|c| plain_member(c)),
NodeKind::Image(_) => warped_image(n),
_ => false,
}
}
/// 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`)?
fn warped_image(n: &Node) -> bool {
let NodeKind::Image(im) = &n.kind else { return false };
im.link.is_none()
&& 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())
}
/// Does `n` have a stroke with a brush that paints, one that its art style holds ([`Writer::style_text`]
/// leaves out what paints nothing)?
fn brushed(n: &Node) -> bool {
@@ -666,7 +679,7 @@ impl Writer<'_> {
return Some(name);
}
let brushed = brushed(orig);
if (orig.appearance.effects.is_empty() && !brushed) || !own_style_holds(&orig.appearance) {
if (orig.appearance.effects.is_empty() && !brushed) || !(own_style_holds(&orig.appearance) || warped_image(orig)) {
return None;
}
let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_)));
@@ -704,7 +717,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), its name and opacity mask; then `1 (name) XW`.
/// one; a group with its members; an image with its pixels), 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
/// style nor as written with a brush on its stroke.
@@ -712,7 +726,7 @@ impl Writer<'_> {
let own = match &orig.kind {
NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()),
NodeKind::Compound { .. } => super::object_count(compound_path(orig).subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()),
NodeKind::Group { .. } => 1,
NodeKind::Group { .. } | NodeKind::Image(_) => 1,
_ => 0,
};
if !self.take_objects(1 + own) {
@@ -752,13 +766,14 @@ impl Writer<'_> {
let saved = std::mem::take(&mut self.body);
self.hidden += 1;
let fit = drawn_outline(n, orig).map(|p| p.bounding_box());
// A path's objects were kept for it above; it counts them itself.
// A path's or an image's objects were kept for it above; it counts them itself.
if !matches!(orig.kind, NodeKind::Group { .. }) {
self.objects -= own;
}
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::Group { children, .. } => {
self.line("u");
for c in children {
@@ -847,7 +862,8 @@ fn object_rule(n: &Node) -> FillRule {
/// Can appearance `ap` be written as a style of an object's own: fills and strokes that paint without
/// transparency or effects of their own, and no Warp, Distort & Transform or Blur effect? Those
/// are written only in the Graphic Styles panel's styles.
/// are written only in the Graphic Styles panel's styles, apart from a Warp on an image
/// ([`warped_image`]).
fn own_style_holds(ap: &Appearance) -> bool {
let plain = |i: &AppearanceItem| i.opacity() >= 1.0 && i.blend() == BlendMode::Normal && i.effects().is_empty();
let panel_only = |e: &Effect| ["warp.", "distort.", "blur."].iter().any(|p| e.id.starts_with(p));
@@ -870,13 +886,16 @@ fn panel_reads(g: &GraphicStyle, text: &str) -> bool {
/// Does the reader read object `n` written with style text `text` as it is: a path or compound
/// path with a style it reads on any object, filled by the non-zero rule when the style fills it,
/// or a group with no fills or strokes of its own, with effects that move the outline only where the
/// reader reads them ([`crate::import::style_moves_with`])?
/// reader reads them ([`crate::import::style_moves_with`]), or an image ([`warped_image`]) with a
/// style of one Warp? The reader takes such an image's Warp where it draws as its drawn look.
fn object_reads(n: &Node, text: &str) -> bool {
let section = known_style("x", text);
let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_)));
let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false };
let group = matches!(n.kind, NodeKind::Group { .. });
if !read.simple
let image = matches!(n.kind, NodeKind::Image(_));
if (image && !(read.image_warp && warped_image(n)))
|| (!image && !read.simple)
|| (group && !read.appearance.items.is_empty())
|| (!group && read.pathfinder)
|| !crate::import::style_moves_with(section.as_bytes(), "x", n)
+5 -1
View File
@@ -483,6 +483,9 @@ struct Reader<'a> {
/// a global process colour, so these make swatches only for names no colour operator defines.
stop_spots: Vec<Named>,
raster: Option<Raster>,
/// The samples of the image being read are on `%_` lines (`%_%%BeginData`): it is the copy of
/// a styled object there, as the other objects on those lines are.
image_hidden: bool,
/// The file `XG` names for the image after it (`XF`), until an image is read.
image_link: Option<String>,
/// The placed file being read (`%AI5_BeginPlace` … `~`).
@@ -736,6 +739,7 @@ impl<'a> Reader<'a> {
spots: Vec::new(),
stop_spots: Vec::new(),
raster: None,
image_hidden: false,
image_link: None,
place: None,
foreign_data: None,
@@ -846,7 +850,7 @@ impl<'a> Reader<'a> {
self.op(w, hidden)?;
}
Tok::Comment(c) => self.comment(c, hidden)?,
Tok::Data(d) => self.image(d)?,
Tok::Data(d) => self.image(d, hidden)?,
Tok::Preview(d) => {
if let Some(p) = self.place.as_mut() {
p.preview = preview_png(d);
+10 -5
View File
@@ -370,15 +370,18 @@ impl<'a> Reader<'a> {
// ---- images ----------------------------------------------------------------------------
/// The samples after `XI`, its operands on the stack.
pub(super) fn image(&mut self, data: &[u8]) -> Result<(), String> {
/// The samples after `XI`, its operands on the stack; `hidden` when they are on `%_` lines.
pub(super) fn image(&mut self, data: &[u8], hidden: bool) -> Result<(), String> {
let vals = self.operands();
// Operands that came before an `XI` on their own line.
let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) {
Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before,
_ => vals,
};
self.image_from(vals, data, false)
self.image_hidden = hidden;
let read = self.image_from(vals, data, false);
self.image_hidden = false;
read
}
/// The matrix and the numbers of an image's operands (`[a b c d tx ty] llx lly urx ury w h bits
@@ -713,7 +716,8 @@ impl<'a> Reader<'a> {
/// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with
/// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the
/// image, and `colorize` as the color that colorizes it.
/// image, and `colorize` as the color that colorizes it. It is on `%_` lines when its samples
/// are ([`Self::image`]).
fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32), colorize: Option<Paint>) -> Result<(), String> {
// The matrix is an art-space matrix (y up) applied to the pixels with their rows running
// downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead
@@ -735,7 +739,8 @@ impl<'a> Reader<'a> {
let key = blob.content_key();
self.doc.images.entry(key.clone()).or_insert(blob);
let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None };
self.add_image_colorized(im, mask, colorize, false)
// An image on `%_` lines is the copy of a styled object, as the other objects there are.
self.add_image_colorized(im, mask, colorize, self.image_hidden)
}
/// An image that isn't read is left out, with `warning`; in a symbol's art, the symbol isn't
+142 -9
View File
@@ -748,6 +748,7 @@ impl<'a> Reader<'a> {
}
let space = self.space();
self.note_brushes_kept();
let page = self.page_in_doc();
let Some(f) = self.frames.last_mut() else { return };
let n = f.children.len();
let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else {
@@ -780,7 +781,7 @@ impl<'a> Reader<'a> {
look
};
// A style read here: the object, painted as it says.
if let Some(mut appearance) = self.styles.get(&style).and_then(|st| {
let appearance = self.styles.get(&style).and_then(|st| {
styled(
object,
look,
@@ -796,13 +797,32 @@ impl<'a> Reader<'a> {
space,
},
)
}) {
// Strokes and effects at the document's scale, as the art (a Large Canvas copy's is scaled up).
let k = space.determinant().abs().sqrt();
if k.is_finite() && k > 0.0 && (k - 1.0).abs() > 1e-9 {
appearance.scale_strokes(k);
appearance.scale_effects(k, vectorcraft_effects::scale_effect);
.map(|mut appearance| {
// Strokes and effects at the document's scale, as the art (a Large Canvas copy's is scaled up).
let k = space.determinant().abs().sqrt();
if k.is_finite() && k > 0.0 && (k - 1.0).abs() > 1e-9 {
appearance.scale_strokes(k);
appearance.scale_effects(k, vectorcraft_effects::scale_effect);
}
appearance
})
});
// An image with a Warp is the object only where its live warp draws as its drawn look, and
// while the import has a check left for it; otherwise its drawn look stays.
let appearance = match appearance {
Some(ap) if matches!(object.kind, NodeKind::Image(_)) && ap.effects.iter().any(|e| e.id.starts_with("warp.")) => {
let alike = self.envelope_checks > 0 && {
self.envelope_checks -= 1;
let layer = self.doc.alloc_id();
let shared = crate::import::ai::envelope::Shared { patterns: &self.doc.patterns, images: &self.doc.images };
let shown = !hidden && object.visible && look.visible;
image_warp_alike(object, &ap, look, &self.hidden_ids, hidden, layer, &shared, page.filter(|_| shown), &mut self.envelope_spent)
};
alike.then_some(ap)
}
other => other,
};
if let Some(appearance) = appearance {
let Some(mut object) = f.children.pop() else { return };
let Some(look) = f.children.pop() else { return };
let left_out = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible);
@@ -1116,6 +1136,41 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
})
}
/// Is `object` an image without an opacity mask, and `st` one Warp on it with nothing else that
/// paints ([`crate::import::ai::style::ArtStyle::image_warp`])? Such an image is read with its Warp
/// live where that draws as its drawn look ([`image_warp_alike`]).
fn image_warps(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
matches!(object.kind, NodeKind::Image(_)) && object.extras().mask.is_none() && st.image_warp
}
/// 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
/// `%_` 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(
object: &Node,
appearance: &Appearance,
look: &Node,
hidden_ids: &HashSet<NodeId>,
hidden: bool,
layer: NodeId,
shared: &crate::import::ai::envelope::Shared<'_>,
page: Option<Rect>,
spent: &mut crate::import::ai::envelope::Spent,
) -> bool {
let mut live = object.clone();
live.appearance = appearance.clone().into();
let mut plain = look.clone();
if !hidden {
strip(&mut plain, hidden_ids);
}
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)
}
/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names.
const PATTERN_KEY: &str = "\u{1}pattern ";
@@ -1128,11 +1183,13 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
let slot = slot_of(object.name.as_deref()).is_some();
// Several fills and strokes, on a path or a compound path.
let stacked = !slot && !st.simple && st.stacked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
// One Warp on an image, checked against its drawn look by the caller ([`image_warp_alike`]).
let image_warp = !st.simple && image_warps(object, st);
// A symbol instance draws its symbol's art: it keeps its drawn look.
if !st.plain
|| matches!(object.kind, NodeKind::SymbolInstance { .. })
// A look with no outline in it to keep takes a variable-width stroke as the object's own.
|| (!slot && !st.simple && !stacked && (!st.uneven || draws(look, &HashSet::new())))
|| (!slot && !st.simple && !stacked && !image_warp && (!st.uneven || draws(look, &HashSet::new())))
|| (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty())
|| (st.pathfinder
&& (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. })))
@@ -1266,6 +1323,9 @@ pub(crate) struct StyleRead {
pub appearance: Appearance,
/// An object other than type is read with it ([`crate::import::ai::style::ArtStyle::simple`]).
pub simple: bool,
/// An image without an opacity mask is read with it where its live warp draws as its drawn
/// look ([`crate::import::ai::style::ArtStyle::image_warp`]).
pub image_warp: bool,
/// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with.
pub pathfinder: bool,
/// The opacity, blending mode, isolation and knockout its blend filter gives.
@@ -1310,7 +1370,7 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
s.brush = Some(brush);
}
}
Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency })
Some(StyleRead { appearance, simple: st.simple, image_warp: st.image_warp, pathfinder: st.pathfinder, transparency: st.transparency })
}
/// Is art style `name` of `section` read on `object` as far as an effect that moves the outline
@@ -1519,6 +1579,79 @@ mod tests {
assert!(styled(&Node::new(NodeId(4), NodeKind::Image(Box::new(image))), &look, st, &paints).is_none());
}
/// The style `Fx`: `effects` (each a filter's name and its dictionary's keys) on the whole
/// object, over a fill and a stroke that `paints` turns on (`0 1`: the fill paints blue).
fn effects_style(effects: &[(&str, &str)], paints: &str) -> crate::import::ai::style::ArtStyle {
let filters: String = effects
.iter()
.map(|(name, keys)| {
format!("/BasicFilter :\n({name}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n")
})
.collect();
let text = format!(
"%AI9_BeginArtStyles\n/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
{filters}/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 1 0 0 0 Xy\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n{paints} Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
;\n /Part ,\n;\n%AI9_EndArtStyles\n"
);
crate::import::ai::style::art_styles(text.as_bytes()).remove("Fx").unwrap_or_default()
}
/// An image takes a style of one Warp, under fills and strokes that paint nothing, and no
/// other style with a geometry effect: those keep its drawn look. A path, and an image with an
/// opacity mask, keep their drawn look with the Warp too.
#[test]
fn an_image_takes_a_style_of_one_warp_alone() {
// GUARDS: an image with a Warp in its art style came in as its drawn look.
let warp = ("Adobe Deform", "1 /Int (DeformStyle) ,\n0.4 /Real (DeformValue) ,\n"); // brand-ok: the filter's name in the files
let (gradients, patterns, names, v7_patterns, bending) =
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new());
let paints = StylePaints {
gradients: &gradients,
patterns: &patterns,
names: &names,
v7_patterns: &v7_patterns,
brushes: &[],
kept: &[],
bending: &bending,
space: Affine::IDENTITY,
};
let look = Node::group(NodeId(1), vec![square(2, Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))]);
let image =
ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let image = Node::new(NodeId(4), NodeKind::Image(Box::new(image)));
let st = effects_style(&[warp], "0 0");
assert!(st.image_warp && !st.simple, "{st:?}");
let ap = styled(&image, &look, &st, &paints).expect("an image takes one Warp");
let ids: Vec<&str> = ap.effects.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["warp.arc"]);
assert!(ap.items.iter().all(|i| i.paint().is_none()), "{ap:?}");
// Not on a path, nor on an image with an opacity mask.
assert!(styled(&square(3, Paint::None), &look, &st, &paints).is_none());
let mut masked = image.clone();
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(7), vec![]), true)));
assert!(styled(&masked, &look, &st, &paints).is_none());
// A Warp over a fill that paints, a Warp with another effect, and the other geometry effects.
let still = ("Adobe Punk and Bloat", "0 /Real (d_factor) ,\n");
let others: [(&[(&str, &str)], &str); 8] = [
(&[warp], "0 1"),
(&[warp, still], "0 0"),
(&[still, warp], "0 0"),
(&[("Adobe Twirl", "45 /Real (angle) ,\n")], "0 0"),
(&[("Adobe Free Distort", "")], "0 0"),
(&[("Adobe Punk and Bloat", "-50 /Real (d_factor) ,\n")], "0 0"),
(&[("Adobe Zigzag", "3 /Real (amount) ,\n1 /Real (absoluteness) ,\n")], "0 0"),
(&[("Adobe Transform", "5 /Real (moveH_Pts) ,\n0.5 /Real (rotate_Radians) ,\n0 /Bool (transformPatterns) ,\n")], "0 0"),
];
for (effects, painted) in others {
let st = effects_style(effects, painted);
assert!(!st.image_warp, "{effects:?} {painted}");
assert!(styled(&image, &look, &st, &paints).is_none(), "{effects:?} {painted}");
}
}
#[test]
fn data_stops_at_its_end_or_a_marker_of_the_section() {
let section = Some(("BeginTextDocument", "EndTextDocument"));
+8
View File
@@ -193,6 +193,10 @@ pub(super) struct ArtStyle {
/// the effects any object takes that don't move the outline: what a path or compound path is
/// read with when each gradient is placed as its drawn look has it (`read::ops`' `styled`).
pub stacked: bool,
/// It is one Warp on the whole object, under fills and strokes that paint nothing and have no
/// effects of their own: what an image is read with when its live warp draws as its drawn look
/// (`read::ops`' `styled`).
pub image_warp: bool,
/// Its fills and strokes, bottom first.
pub items: Vec<StyleItem>,
/// Each item's own visibility, transparency and effects, in the order of [`Self::items`].
@@ -1778,6 +1782,10 @@ fn style(filters: &[Filter]) -> ArtStyle {
&& st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Gradient { .. } | StyleItem::Stroke { brush: None, .. }))
&& st.looks.iter().all(|l| l.effects.is_empty())
&& st.effects.iter().all(any_object_takes);
st.image_warp = st.plain
&& matches!(st.effects.as_slice(), [e] if e.id.starts_with("warp."))
&& st.items.iter().all(|i| matches!(i, StyleItem::Fill(p) | StyleItem::Stroke { paint: p, .. } if p.is_none()))
&& st.looks.iter().all(|l| l.effects.is_empty());
st
}
+1
View File
@@ -613,6 +613,7 @@ 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.
- **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.
- **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.