Merge pull request #5139 from dotCooCoo/fix/ai-os10-distort

.ai import: type keeps its Distort & Transform effects live
This commit is contained in:
BFlat
2026-11-02 16:54:41 +07:00
committed by GitHub
7 changed files with 597 additions and 24 deletions
+4 -2
View File
@@ -298,7 +298,8 @@ pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData {
}
/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle,
/// making at most `max` anchors ([`map_nonlinear_within`]).
/// clockwise on the page for a positive `angle`, making at most `max` anchors
/// ([`map_nonlinear_within`]).
pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData {
let ang = angle.clamp(-3600.0, 3600.0).to_radians();
if ang == 0.0 {
@@ -309,7 +310,8 @@ pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData {
let f = move |q: Point| {
let d = q - c;
let t = (1.0 - d.hypot() / r).max(0.0);
let a = -ang * t;
// y runs down, so a positive turn is clockwise on the page.
let a = ang * t;
let (s, co) = a.sin_cos();
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
};
+12
View File
@@ -942,6 +942,18 @@ fn round_corners_keeps_where_a_closed_path_starts() {
assert_eq!(stylize::round_corners(&d, f64::INFINITY), stylize::round_corners(&d, 1e9));
}
#[test]
fn twist_turns_clockwise_for_positive_angles() {
// A positive angle turns clockwise, a negative one counterclockwise
// (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html).
// The document's y runs down, so clockwise on the page takes a point right of the center down.
let right = PathData::single(SubPath::polyline(&[Point::new(55.0, 50.0), Point::new(56.0, 50.0)], false));
let bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
let turned = |angle: f64| apply_geometry(&[fx("distort.twist", json!({"angle": angle}))], &right, bounds).subpaths[0].anchors[0].p;
assert!(turned(45.0).y > 50.5, "{:?}", turned(45.0));
assert!(turned(-45.0).y < 49.5, "{:?}", turned(-45.0));
}
#[test]
fn twist_fixes_the_outer_circle_and_rotates_inside() {
let out = run("distort.twist", json!({"angle": 0}), &square());
+78
View File
@@ -3906,6 +3906,29 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"(Fill Style Filter) 0 0 /Filter ,",
"(Adobe Outer Glow) 1 0 /Filter ,",
"(Adobe Drop Shadow) 1 0 /Filter ,",
"(Adobe Transform) 1 0 /Filter ,",
"(Adobe Free Distort) 1 0 /Filter ,",
"(Adobe Punk and Bloat) 1 0 /Filter ,",
"(Adobe Roughen) 1 0 /Filter ,",
"(Adobe Scribble and Tweak) 1 0 /Filter ,",
"(Adobe Twirl) 1 0 /Filter ,",
"(Adobe Zigzag) 1 0 /Filter ,",
"1e308 /Real (scaleH_Factor) ,",
"-1e308 /Real (rotate_Radians) ,",
"1000 /Int (numCopies) ,",
"8 /Int (pinPoint) ,",
"0 /Bool (scaleLines) ,",
"0 /Bool (transformPatterns) ,",
"0 /Real (src0h) ,",
"1e-300 /Real (src1h) ,",
"-1 /Real (src2v) ,",
"1e308 /Real (dst3v) ,",
"-200 /Real (d_factor) ,",
"3600 /Real (angle) ,",
"1 /Real (absoluteness) ,",
"100 /Real (ridges) ,",
"1e4 /Real (amount) ,",
"0 /Bool (anch) ,",
"(Adobe Inner Glow) 1 0 /Filter ,",
"(Adobe Fuzzy Mask) 1 0 /Filter ,",
"(Adobe Round Corners) 1 0 /Filter ,",
@@ -4146,6 +4169,61 @@ proptest! {
survive("Path effect art style in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// Art styles with a Distort & Transform effect (Transform, Free Distort, Pucker & Bloat, Roughen,
/// Tweak, Twist, Zig Zag) whose dictionary holds any of its entries, and unknown ones, as any kind,
/// with any value (huge, tiny, not a number, a string), repeated or left out, on a path and on type,
/// on the whole object or on its fill.
#[test]
fn ai_distort_effects_never_panic(
name in prop::sample::select(vec![
"Adobe Transform",
"Adobe Free Distort",
"Adobe Punk and Bloat",
"Adobe Roughen",
"Adobe Scribble and Tweak",
"Adobe Twirl",
"Adobe Zigzag",
]),
entries in prop::collection::vec((
prop::sample::select(vec![
"scaleH_Factor", "scaleV_Factor", "scaleH_Percent", "rotate_Radians", "rotate_Degrees", "moveH_Pts", "moveV_Pts",
"numCopies", "pinPoint", "reflectX", "reflectY", "randomize", "transformObjects", "transformPatterns", "scaleLines",
"src0h", "src0v", "src1h", "src1v", "src2h", "src2v", "src3h", "src3v", "dst0h", "dst0v", "dst1h", "dst1v", "dst2h",
"dst2v", "dst3h", "dst3v", "d_factor", "absoluteness", "size", "asiz", "dtal", "roundness", "horz", "vert", "ahor",
"aver", "anch", "in", "out", "angle", "amount", "relAmount", "ridges", "futureKey",
]),
prop::sample::select(vec!["Int", "Real", "Bool"]),
prop_oneof![
(-1e4f64..1e4).prop_map(|v| v.to_string()),
prop::sample::select(vec!["0", "1", "-1", "0.5", "1e-300", "1e308", "-1e308", "1e999", "NaN", "(x)", ""]).prop_map(str::to_string),
],
), 0..24),
on_fill in any::<bool>(),
) {
use vectorcraft_testkit::ai;
let keys: String = entries.iter().map(|(k, kind, v)| format!("{v} /{kind} ({k}) ,\n")).collect();
let basic = |name: &str, body: &str| format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n");
let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat());
let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n");
let effect = basic(name, &keys);
let root = if on_fill {
compound("Chain Style Filter", &[compound("Stack Style Filter", &[compound("Chain Style Filter", &[fill, effect]), basic("Conduit Filter", "")])])
} else {
compound("Chain Style Filter", &[compound("Stack Style Filter", &[fill, basic("Conduit Filter", "")]), effect])
};
let styles = format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{root}%AI9_EndArtStyles\n");
let hidden = ai::rect(20.0, 20.0, 80.0, 60.0).replace('\n', "\n%_");
let art = format!(
"0 Ae\nu\n0 0 1 0 k\n{}f\nU\n%_{hidden}f\n1 (Fx) XW\nu\n/AI11Text :\n1 /FreeUndo ,\n;\n0 0 1 0 k\n{}f\nU\n1 (Fx) XW\n",
ai::rect(20.0, 20.0, 80.0, 60.0),
ai::rect(100.0, 20.0, 180.0, 60.0)
);
let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("L", &art)));
survive("distort art style", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
let eps = ai::eps(data.as_bytes(), ai::page_ps());
survive("distort art style in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// Layer options (`Lb`) of any number of operands with any values, and guide paths (`(N) *`,
/// `(S) *`) of any points, among them ones across the whole canvas or from edge to edge of
/// artboards that overlap or are the same, on layers and sublayers.
+217 -21
View File
@@ -60,6 +60,11 @@ const OFFSET: &str = "Adobe Offset Path"; // brand-ok: the filter's name in the
const OUTLINE_TYPE: &str = "Adobe Outline Type"; // brand-ok: the filter's name in the files
const OUTLINE_STROKE: &str = "Adobe Outline Stroke"; // brand-ok: the filter's name in the files
const TRANSFORM: &str = "Adobe Transform"; // brand-ok: the filter's name in the files
const FREE_DISTORT: &str = "Adobe Free Distort"; // brand-ok: the filter's name in the files
const PUCKER_BLOAT: &str = "Adobe Punk and Bloat"; // brand-ok: the filter's name in the files
const TWEAK: &str = "Adobe Scribble and Tweak"; // brand-ok: the filter's name in the files
const TWIST: &str = "Adobe Twirl"; // brand-ok: the filter's name in the files
const ZIG_ZAG: &str = "Adobe Zigzag"; // brand-ok: the filter's name in the files
const GAUSSIAN: &str = "Adobe PSL Gaussian Blur"; // brand-ok: the filter's name in the files
const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl";
const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files
@@ -546,6 +551,186 @@ fn width_profile(arrays: &HashMap<String, Vec<f64>>) -> Option<WidthProfile> {
(!uniform).then_some(WidthProfile { points })
}
/// Entry `key` of `f`'s dictionary, written as `kind`: `Ok(None)` when the dictionary doesn't hold
/// it, `Err` when it holds it as another kind, without a finite number, or as an `/Int` that isn't
/// whole or a `/Bool` other than 0 and 1.
fn entry(f: &Filter, key: &str, kind: Kind) -> Result<Option<f64>, ()> {
let Some(k) = f.kinds.get(key) else { return Ok(None) };
match f.params.get(key) {
Some(v)
if *k == kind
&& v.is_finite()
&& match kind {
Kind::Real => true,
Kind::Int => v.fract() == 0.0,
Kind::Bool => *v == 0.0 || *v == 1.0,
Kind::Fill | Kind::Other => false,
} =>
{
Ok(Some(*v))
}
_ => Err(()),
}
}
/// Entry `key` as a switch, 0 or 1 written as `kind`: `Ok(None)` when it is missing.
fn switch(f: &Filter, key: &str, kind: Kind) -> Result<Option<bool>, ()> {
match entry(f, key, kind)? {
None => Ok(None),
Some(0.0) => Ok(Some(false)),
Some(1.0) => Ok(Some(true)),
Some(_) => Err(()),
}
}
/// Real entry `key` within `range`, `missing` when the dictionary doesn't hold it.
fn real(f: &Filter, key: &str, missing: f64, range: std::ops::RangeInclusive<f64>) -> Option<f64> {
entry(f, key, Kind::Real).ok()?.map_or(Some(missing), |v| range.contains(&v).then_some(v))
}
/// What an effect in a style could change the paint of, for the Transform options whose value,
/// when the dictionary leaves it out, comes from the app's settings rather than the file.
#[derive(Clone, Copy, Debug)]
struct Under {
/// A stroke, or other effects with distances that Scale Strokes & Effects would scale.
strokes: bool,
/// A pattern.
patterns: bool,
}
/// Anything: an effect on the whole object, whose contents can paint in any way.
const ANYTHING: Under = Under { strokes: true, patterns: true };
/// The Transform effect `f` (the factor and radian entries; the percent and degree ones are what
/// its dialog shows). `None` keeps the drawn look: an entry not read, Random (the file holds no
/// seed), or a missing Scale Strokes & Effects or Transform Patterns that would change `under`.
fn transform(f: &Filter, under: Under) -> Option<serde_json::Value> {
for key in ["scaleH_Percent", "scaleV_Percent", "rotate_Degrees"] {
entry(f, key, Kind::Real).ok()?;
}
// The renderer's limits on scales and moves (`vectorcraft_effects`' `MAX_SCALE` and `MAX_MOVE`).
let scale = |key: &str| real(f, key, 1.0, -1000.0..=1000.0).map(|v| v * 100.0);
let (h, v) = (scale("scaleH_Factor")?, scale("scaleV_Factor")?);
let (move_h, move_v) = (real(f, "moveH_Pts", 0.0, -1e7..=1e7)?, real(f, "moveV_Pts", 0.0, -1e7..=1e7)?);
let rotate = Some(entry(f, "rotate_Radians", Kind::Real).ok()?.unwrap_or(0.0).to_degrees()).filter(|d| d.is_finite())?;
let copies = entry(f, "numCopies", Kind::Int).ok()?.unwrap_or(0.0);
let reference = entry(f, "pinPoint", Kind::Int).ok()?.unwrap_or(4.0);
if !(0.0..=1000.0).contains(&copies) || !(0.0..=8.0).contains(&reference) {
return None;
}
let on = |key: &str| switch(f, key, Kind::Bool);
let (reflect_x, reflect_y) = (on("reflectX").ok()?.unwrap_or(false), on("reflectY").ok()?.unwrap_or(false));
let objects = on("transformObjects").ok()?.unwrap_or(true);
let (strokes, patterns) = (on("scaleLines").ok()?, on("transformPatterns").ok()?);
if on("randomize").ok()?.unwrap_or(false) {
return None;
}
let moves = h != 100.0 || v != 100.0 || move_h != 0.0 || move_v != 0.0 || rotate != 0.0 || reflect_x || reflect_y;
let scales = ((h * v).abs().sqrt() - 100.0).abs() > 1e-9;
if (strokes.is_none() && scales && under.strokes) || (patterns.is_none() && moves && under.patterns) {
return None;
}
// Art space has y up: a move up is a negative `moveV` in the document.
Some(json!({
"scaleH": h,
"scaleV": v,
"moveH": move_h,
"moveV": 0.0 - move_v,
"rotate": rotate,
"copies": copies as i64,
"reflectX": reflect_x,
"reflectY": reflect_y,
"reference": reference as i64,
"objects": objects,
"patterns": patterns.unwrap_or(false),
"strokes": strokes.unwrap_or(false),
}))
}
/// Free Distort's corners: the destination corners `dst0h`… measured in the source frame `src0h`…
/// (top left, top right, bottom left, bottom right, y up), as the catalog's unit-box corners (top
/// left, top right, bottom right, bottom left, y down). The source frame is only the unit the
/// corners are measured in: the distorted box is laid over the object's own bounds, as the catalog
/// effect lays it, also when the frame is another size (a unit square, or bounds with the stroke).
/// No corners is no change; some but not all, or a frame that isn't an upright rectangle, isn't read.
fn free_distort(f: &Filter) -> Option<serde_json::Value> {
let mut corners = [[[0.0; 2]; 4]; 2];
let mut held = 0;
for (which, c) in ["src", "dst"].into_iter().zip(&mut corners) {
for (i, corner) in c.iter_mut().enumerate() {
for (axis, v) in ["h", "v"].into_iter().zip(corner.iter_mut()) {
if let Some(x) = entry(f, &format!("{which}{i}{axis}"), Kind::Real).ok()? {
*v = x;
held += 1;
}
}
}
}
if held == 0 {
return Some(json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}));
}
let [[tl, tr, bl, br], dst] = corners;
let (left, top) = (tl[0], tl[1]);
let (width, height) = (tr[0] - left, top - bl[1]);
let tolerance = 1e-5 * width.abs().max(height.abs());
let upright = (tr[1] - top).abs() <= tolerance
&& (br[1] - bl[1]).abs() <= tolerance
&& (bl[0] - left).abs() <= tolerance
&& (br[0] - tr[0]).abs() <= tolerance;
let extent = |d: f64| d.is_finite() && d > 0.0;
if held < 16 || !upright || !extent(width) || !extent(height) {
return None;
}
let mut out = vec![];
for i in [0, 1, 3, 2] {
let [h, v] = *dst.get(i)?;
let (x, y) = ((h - left) / width, (top - v) / height);
if !(x.is_finite() && y.is_finite() && x.abs() <= 1e4 && y.abs() <= 1e4) {
return None;
}
out.push([x, y]);
}
Some(json!({ "corners": out }))
}
/// A `0` relative, `1` absolute switch (`absoluteness`), `absolute` when it is missing, and the size
/// it picks: `relative_key` in percent or `absolute_key` in points, 0 when missing.
fn sized(f: &Filter, absolute: bool, relative_key: &str, absolute_key: &str) -> Option<(bool, f64)> {
let absolute = switch(f, "absoluteness", Kind::Real).ok()?.unwrap_or(absolute);
let relative = real(f, relative_key, 0.0, 0.0..=1e4)?;
let points = real(f, absolute_key, 0.0, 0.0..=1e4)?;
Some((absolute, if absolute { points } else { relative }))
}
/// Corner (`roundness` 0, or missing) or Smooth (1).
fn points(f: &Filter) -> Option<&'static str> {
Some(if switch(f, "roundness", Kind::Real).ok()?.unwrap_or(false) { "smooth" } else { "corner" })
}
/// The Tweak effect `f` (`Adobe Scribble and Tweak`): relative unless `absoluteness` says otherwise,
/// moving anchors and both handles unless the dictionary turns them off.
fn tweak(f: &Filter) -> Option<serde_json::Value> {
let (absolute, h) = sized(f, false, "horz", "ahor")?;
let (_, v) = sized(f, false, "vert", "aver")?;
let on = |key: &str| switch(f, key, Kind::Bool).map(|v| v.unwrap_or(true));
let (anchors, ins, outs) = (on("anch").ok()?, on("in").ok()?, on("out").ok()?);
Some(json!({"h": h, "v": v, "relative": !absolute, "anchors": anchors, "in": ins, "out": outs}))
}
/// The Zig Zag effect `f`: absolute unless `absoluteness` says otherwise, 4 ridges and Corner points
/// when they are missing.
fn zig_zag(f: &Filter) -> Option<serde_json::Value> {
let (absolute, size) = sized(f, true, "relAmount", "amount")?;
let ridges = real(f, "ridges", 4.0, 0.0..=100.0).filter(|r| r.fract() == 0.0)?;
Some(json!({"size": size, "relative": !absolute, "ridges": ridges as i64, "points": points(f)?}))
}
/// Whether Tweak params `p` leave the outline as it is: its random outline isn't in the file, so any
/// other keeps the drawn look.
fn still(p: &serde_json::Value) -> bool {
(p["h"].as_f64() == Some(0.0) && p["v"].as_f64() == Some(0.0)) || ["anchors", "in", "out"].iter().all(|k| p[k] == json!(false))
}
/// A Stylize effect's dictionary, read only as it is written: each setting a key of the kind it
/// names, with a finite value in the effect's range.
struct Settings<'a>(&'a Filter);
@@ -763,8 +948,9 @@ fn path_effect(f: &Filter) -> Option<Effect> {
Some(Effect { id: id.into(), params, visible: f.visible })
}
/// The live effect filter `f` is, when it is one read here.
fn effect(f: &Filter) -> Option<Effect> {
/// The live effect filter `f` is, when it is one read here; `under` is what it could change the
/// paint of.
fn effect(f: &Filter, under: Under) -> Option<Effect> {
if let Some(stylized) = stylize(f) {
let (id, params) = stylized?;
return Some(Effect { id: id.into(), params, visible: f.visible });
@@ -789,22 +975,14 @@ fn effect(f: &Filter) -> Option<Effect> {
)
}
OFFSET | OUTLINE_TYPE | OUTLINE_STROKE => return path_effect(f),
// Art space has y up: a move up is a negative `moveV` in the document.
TRANSFORM => (
"distort.transform",
json!({
"scaleH": param("scaleH_Percent").unwrap_or(100.0),
"scaleV": param("scaleV_Percent").unwrap_or(100.0),
"moveH": param("moveH_Pts").unwrap_or(0.0),
"moveV": -param("moveV_Pts").unwrap_or(0.0),
"rotate": param("rotate_Degrees").unwrap_or(0.0),
"copies": param("numCopies").unwrap_or(0.0).clamp(0.0, 1000.0) as i64,
"reflectX": flag("reflectX"),
"reflectY": flag("reflectY"),
"reference": param("pinPoint").unwrap_or(4.0).clamp(0.0, 8.0) as i64,
"random": flag("randomize"),
}),
),
TRANSFORM => ("distort.transform", transform(f, under)?),
FREE_DISTORT => ("distort.freeDistort", free_distort(f)?),
// Negative puckers (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html).
PUCKER_BLOAT => ("distort.puckerBloat", json!({"amount": real(f, "d_factor", 0.0, -200.0..=200.0)?})),
TWEAK => ("distort.tweak", tweak(f).filter(still)?),
// A positive angle turns clockwise in both.
TWIST => ("distort.twist", json!({"angle": real(f, "angle", 0.0, -3600.0..=3600.0)?})),
ZIG_ZAG => ("distort.zigZag", zig_zag(f)?),
GAUSSIAN => ("blur.gaussian", json!({"radius": param("blur").unwrap_or(5.0).clamp(0.0, 1000.0)})),
// Its settings are in a Photoshop plug-in's binary block: the effect's defaults stand in.
BAS_RELIEF => ("sketch.basRelief", json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})),
@@ -878,7 +1056,25 @@ impl Builder<'_> {
let mut look = ItemLook { visible: f.visible, ..ItemLook::default() };
let mut painted: Option<StyleItem> = None;
let mut paints = 0;
for k in self.children(id) {
let kids = self.children(id);
// What this item's effects could change: its own paint, and its other effects' distances.
let mut under = Under { strokes: false, patterns: false };
let mut effects = 0;
for &k in &kids {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => {}
FILL | STROKE => match paint_item(c) {
Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true,
Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true,
_ => {}
},
BLEND => {}
_ => effects += 1,
}
}
under.strokes |= effects > 1;
for k in kids {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => self.plain = false,
@@ -897,7 +1093,7 @@ impl Builder<'_> {
}
// Convert to Shape on one fill or stroke isn't read yet: what it draws there is
// the drawn look's.
_ => match effect(c) {
_ => match effect(c, under) {
Some(e) if !e.id.starts_with("convertToShape.") => look.effects.push(e),
_ => self.plain = false,
},
@@ -955,7 +1151,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
st.transparency = Some((opacity, super::read::blend_mode(mode), isolate, knockout));
}
}
_ => match effect(f) {
_ => match effect(f, ANYTHING) {
Some(e) => st.effects.push(e),
None => b.plain = false,
},
+220
View File
@@ -593,6 +593,226 @@ fn offset_path_with_settings_not_read_keeps_the_drawn_look() {
}
}
/// The style `Fx`: effect `name` on the whole object, its dictionary holding `keys`.
fn distort_effect_style(name: &str, keys: &str) -> ArtStyle {
let data = format!("/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[basic(name, keys)]));
art_styles(data.as_bytes()).remove("Fx").unwrap_or_default()
}
/// The params of effect `name` with `keys`, `None` when the style keeps the drawn look.
fn read_effect(name: &str, keys: &str) -> Option<serde_json::Value> {
let st = distort_effect_style(name, keys);
if !st.plain {
return None;
}
let [e] = st.effects.as_slice() else { panic!("{st:?}") };
Some(e.params.clone())
}
/// A filter named `name` whose dictionary holds `entries`.
fn dict(name: &str, entries: &[(&str, Kind, f64)]) -> Filter {
let kinds = entries.iter().map(|(k, kind, _)| (k.to_string(), *kind)).collect();
let params = entries.iter().map(|(k, _, v)| (k.to_string(), *v)).collect();
Filter { name: name.into(), kinds, params, ..Filter::default() }
}
fn close(a: &serde_json::Value, want: f64) -> bool {
a.as_f64().is_some_and(|a| (a - want).abs() < 1e-6)
}
/// Every switch of the Transform effect, as files write them.
const SWITCHES: &str = "0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n";
#[test]
fn a_transform_reads_its_factors_and_radians() {
// GUARDS: the percent and degree entries (what the dialog shows) were read, and the options were not.
let p = read_effect(
"Adobe Transform",
"150 /Real (scaleH_Percent) ,\n1.4 /Real (scaleH_Factor) ,\n80 /Real (scaleV_Percent) ,\n1.2 /Real (scaleV_Factor) ,\n\
30 /Real (rotate_Degrees) ,\n0.471238898 /Real (rotate_Radians) ,\n-8 /Real (moveV_Pts) ,\n3 /Real (moveH_Pts) ,\n\
2 /Int (numCopies) ,\n0 /Int (pinPoint) ,\n1 /Bool (reflectX) ,\n0 /Bool (transformObjects) ,\n1 /Bool (transformPatterns) ,\n\
1 /Bool (scaleLines) ,\n0 /Bool (randomize) ,\n",
)
.unwrap();
assert!(close(&p["scaleH"], 140.0) && close(&p["scaleV"], 120.0) && close(&p["rotate"], 27.0), "{p}");
assert_eq!((&p["moveH"], &p["moveV"], &p["copies"], &p["reference"]), (&json!(3.0), &json!(8.0), &json!(2), &json!(0)));
assert_eq!((&p["reflectX"], &p["reflectY"]), (&json!(true), &json!(false)));
assert_eq!((&p["objects"], &p["patterns"], &p["strokes"]), (&json!(false), &json!(true), &json!(true)));
}
#[test]
fn a_transforms_percent_and_degrees_alone_change_nothing() {
let p = read_effect("Adobe Transform", &format!("150 /Real (scaleH_Percent) ,\n30 /Real (rotate_Degrees) ,\n{SWITCHES}")).unwrap();
assert_eq!((&p["scaleH"], &p["scaleV"], &p["rotate"]), (&json!(100.0), &json!(100.0), &json!(0.0)));
}
#[test]
fn a_transforms_reference_point_and_move_up() {
for pin in 0..9 {
let p = read_effect("Adobe Transform", &format!("0.5 /Real (scaleH_Factor) ,\n{pin} /Int (pinPoint) ,\n{SWITCHES}")).unwrap();
assert_eq!(p["reference"], json!(pin));
}
// A move up the page in the file is a move up in the document, whose y runs down.
let p = read_effect("Adobe Transform", &format!("10 /Real (moveV_Pts) ,\n{SWITCHES}")).unwrap();
assert_eq!(p["moveV"], json!(-10.0));
let p = read_effect("Adobe Transform", &format!("4 /Int (pinPoint) ,\n{SWITCHES}")).unwrap();
assert_eq!((&p["objects"], &p["copies"], &p["scaleH"]), (&json!(true), &json!(0), &json!(100.0)));
}
#[test]
fn a_transform_without_the_options_its_change_depends_on_keeps_the_drawn_look() {
// Missing Scale Strokes & Effects or Transform Patterns are the app's settings, not the file's.
let half = "0.5 /Real (scaleH_Factor) ,\n0.5 /Real (scaleV_Factor) ,\n";
assert_eq!(read_effect("Adobe Transform", &format!("{half}0 /Bool (transformPatterns) ,\n")), None);
assert_eq!(read_effect("Adobe Transform", &format!("{half}0 /Bool (scaleLines) ,\n")), None);
// A move scales nothing; with Transform Patterns given, it reads.
let p = read_effect("Adobe Transform", "5 /Real (moveH_Pts) ,\n0 /Bool (transformPatterns) ,\n").unwrap();
assert_eq!(p["strokes"], json!(false));
// On a fill's own Transform, only that fill's paint counts: a solid fill moves the same either way.
let moved = basic("Adobe Transform", "5 /Real (moveH_Pts) ,\n");
let item = |paint: &str| {
let item = compound("Chain Style Filter", &[fill(paint), moved.clone()]);
let data =
format!("/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])]));
art_styles(data.as_bytes()).remove("Fx").unwrap_or_default().plain
};
assert!(item("0 0 0 1 0 0 0 Xa"));
assert!(!item("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"));
}
#[test]
fn a_random_transform_keeps_the_drawn_look() {
// The file holds no seed for its random share.
assert_eq!(read_effect("Adobe Transform", &format!("1 /Bool (randomize) ,\n{SWITCHES}")), None);
}
/// Free Distort's dictionary: the source corners `src` and the destination `dst`, top left, top
/// right, bottom left, bottom right, y up.
fn corners(src: [[f64; 2]; 4], dst: [[f64; 2]; 4]) -> String {
let mut out = String::new();
for (which, c) in [("src", src), ("dst", dst)] {
for (i, [h, v]) in c.into_iter().enumerate() {
out += &format!("{h} /Real ({which}{i}h) ,\n{v} /Real ({which}{i}v) ,\n");
}
}
out
}
fn unit_corners(p: &serde_json::Value) -> Vec<[f64; 2]> {
p["corners"].as_array().unwrap().iter().map(|c| [c[0].as_f64().unwrap(), c[1].as_f64().unwrap()]).collect()
}
fn corners_near(a: &[[f64; 2]], b: &[[f64; 2]]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9)
}
#[test]
fn free_distort_measures_its_corners_in_its_own_frame() {
// GUARDS: Free Distort wasn't read, and type with it came in as its drawn look.
let dst = [[180.0, 310.0], [320.0, 300.0], [210.0, 240.0], [250.0, 260.0]];
// A frame wider than the object (its stroke's): the corners are fractions of that frame.
let p = read_effect("Adobe Free Distort", &corners([[195.0, 305.0], [305.0, 305.0], [195.0, 245.0], [305.0, 245.0]], dst)).unwrap();
let want = [[-15.0 / 110.0, -5.0 / 60.0], [125.0 / 110.0, 5.0 / 60.0], [0.5, 0.75], [15.0 / 110.0, 65.0 / 60.0]];
assert!(corners_near(&unit_corners(&p), &want), "{p}");
// A unit frame: the same corners measured in it, far outside the unit box.
let p = read_effect("Adobe Free Distort", &corners([[0.0, 0.0], [1.0, 0.0], [0.0, -1.0], [1.0, -1.0]], dst)).unwrap();
assert!(corners_near(&unit_corners(&p), &[[180.0, -310.0], [320.0, -300.0], [250.0, -260.0], [210.0, -240.0]]), "{p}");
// No corners: no change.
let p = read_effect("Adobe Free Distort", "").unwrap();
assert!(corners_near(&unit_corners(&p), &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), "{p}");
}
#[test]
fn free_distort_without_every_corner_or_an_upright_frame_keeps_the_drawn_look() {
let src = [[0.0, 0.0], [1.0, 0.0], [0.0, -1.0], [1.0, -1.0]];
let dst = [[0.1, 0.0], [1.0, 0.1], [0.0, -1.0], [1.0, -1.2]];
let all = corners(src, dst);
// The destination alone, one entry missing, a frame that is turned or of no height.
let dst_only: String = all.lines().filter(|l| l.contains("(dst")).map(|l| format!("{l}\n")).collect();
let short = all.replace("-1.2 /Real (dst3v) ,\n", "");
for keys in [
dst_only,
short,
corners([[0.0, 0.0], [1.0, 0.5], [0.0, -1.0], [1.0, -1.0]], dst),
corners([[0.0, 0.0], [1.0, 0.0], [0.0, 0.0], [1.0, 0.0]], dst),
// A frame too wide for a number.
corners([[-1e308, 0.0], [1e308, 0.0], [-1e308, -1.0], [1e308, -1.0]], dst),
] {
assert_eq!(read_effect("Adobe Free Distort", &keys), None, "{keys}");
}
}
#[test]
fn pucker_and_twist_keep_their_sense() {
// Negative is Pucker; a positive angle turns clockwise in the file and in the catalog.
assert_eq!(read_effect("Adobe Punk and Bloat", "-35 /Real (d_factor) ,\n"), Some(json!({"amount": -35.0})));
assert_eq!(read_effect("Adobe Punk and Bloat", ""), Some(json!({"amount": 0.0})));
assert_eq!(read_effect("Adobe Twirl", "45 /Real (angle) ,\n"), Some(json!({"angle": 45.0})));
assert_eq!(read_effect("Adobe Twirl", ""), Some(json!({"angle": 0.0})));
assert_eq!(read_effect("Adobe Punk and Bloat", "250 /Real (d_factor) ,\n"), None);
assert_eq!(read_effect("Adobe Twirl", "4000 /Real (angle) ,\n"), None);
}
#[test]
fn roughen_keeps_the_drawn_look() {
// Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the
// path's segments.
assert_eq!(read_effect("Adobe Roughen", "30 /Real (size) ,\n"), None);
assert_eq!(read_effect("Adobe Roughen", ""), None);
}
#[test]
fn tweak_picks_its_amounts_by_its_mode_and_moves_every_point_unless_told() {
let amounts = [("horz", Kind::Real, 30.0), ("vert", Kind::Real, 20.0), ("ahor", Kind::Real, 2.0), ("aver", Kind::Real, 3.0)];
let mut absolute = amounts.to_vec();
absolute.push(("absoluteness", Kind::Real, 1.0));
let want = |h: f64, v: f64, relative: bool| json!({"h": h, "v": v, "relative": relative, "anchors": true, "in": true, "out": true});
assert_eq!(tweak(&dict("Adobe Scribble and Tweak", &amounts)), Some(want(30.0, 20.0, true)));
assert_eq!(tweak(&dict("Adobe Scribble and Tweak", &absolute)), Some(want(2.0, 3.0, false)));
let off = tweak(&dict("Adobe Scribble and Tweak", &[("in", Kind::Bool, 0.0)])).unwrap();
assert_eq!((&off["anchors"], &off["in"], &off["out"]), (&json!(true), &json!(false), &json!(true)));
// Random too: it reads only when it moves nothing.
assert_eq!(read_effect("Adobe Scribble and Tweak", "30 /Real (horz) ,\n"), None);
assert!(read_effect("Adobe Scribble and Tweak", "").is_some());
let still = "30 /Real (horz) ,\n0 /Bool (anch) ,\n0 /Bool (in) ,\n0 /Bool (out) ,\n";
assert!(read_effect("Adobe Scribble and Tweak", still).is_some());
}
#[test]
fn zig_zag_is_absolute_with_four_ridges_and_corners_unless_told() {
let p = read_effect("Adobe Zigzag", "30 /Real (relAmount) ,\n2 /Real (amount) ,\n").unwrap();
assert_eq!(p, json!({"size": 2.0, "relative": false, "ridges": 4, "points": "corner"}));
let p = read_effect(
"Adobe Zigzag",
"30 /Real (relAmount) ,\n2 /Real (amount) ,\n0 /Real (absoluteness) ,\n6 /Real (ridges) ,\n1 /Real (roundness) ,\n",
)
.unwrap();
assert_eq!(p, json!({"size": 30.0, "relative": true, "ridges": 6, "points": "smooth"}));
assert_eq!(read_effect("Adobe Zigzag", "2.5 /Real (ridges) ,\n"), None);
}
#[test]
fn a_distort_entry_this_cant_read_keeps_the_drawn_look_and_unknown_ones_are_ignored() {
// Not a number, the wrong kind, a switch other than 0 or 1, a whole number that isn't.
for (name, keys) in [
("Adobe Twirl", "NaN /Real (angle) ,\n"),
("Adobe Twirl", "45 /Int (angle) ,\n"),
("Adobe Zigzag", "2 /Real (absoluteness) ,\n"),
("Adobe Transform", "4 /Real (pinPoint) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"),
("Adobe Transform", "2 /Bool (reflectX) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"),
("Adobe Transform", "1e999 /Real (scaleH_Percent) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"),
("Adobe Transform", "1001 /Int (numCopies) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"),
// In degrees, too large for a number.
("Adobe Transform", "1e308 /Real (rotate_Radians) ,\n0 /Bool (scaleLines) ,\n0 /Bool (transformPatterns) ,\n"),
("Adobe Free Distort", "(x) /Real (src0h) ,\n"),
] {
assert_eq!(read_effect(name, keys), None, "{name}: {keys}");
}
assert_eq!(read_effect("Adobe Twirl", "37 /Real (angle) ,\n9 /Real (futureKey) ,\n"), Some(json!({"angle": 37.0})));
// The last of a repeated entry stands.
assert_eq!(read_effect("Adobe Twirl", "37 /Real (angle) ,\n12 /Real (angle) ,\n"), Some(json!({"angle": 12.0})));
}
#[test]
fn a_hidden_fill_comes_in_hidden() {
let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1);
+65
View File
@@ -3459,6 +3459,71 @@ fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() {
check(&r.document, &r.warnings);
}
/// An art style section with style `Bent`: a blue fill over the characters, and effect `name`, its
/// dictionary holding `keys`, on the whole object.
fn whole_object_style(name: &str, keys: &str) -> String {
let basic = |name: &str, body: &str| {
format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n")
};
let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat());
let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n");
let stack = compound("Stack Style Filter", &[fill, basic("Conduit Filter", "")]);
let root = compound("Chain Style Filter", &[stack, basic(name, keys)]);
format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Bent) /Name ,\n/ActiveStyle :\n{root}%AI9_EndArtStyles\n")
}
#[test]
fn type_with_a_distort_effect_comes_in_as_type_with_it_live() {
// GUARDS: Free Distort, Pucker & Bloat, Twist, Zig Zag and Tweak weren't read, and type with them came in
// without its art style, the style's drawing beside it as paths.
use serde_json::json;
let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let drawing = "0 0 1 0 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf";
let look = format!("u\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n");
let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
let open = |name: &str, keys: &str| {
let art = native_with_text(&(whole_object_style(name, keys) + &layer("Art", true, &format!("{look}{own}1 (Bent) XW\n"))), &doc);
let (d, _) = crate::ai_alone(&private(&art)).unwrap();
let (mut texts, mut paths) = (vec![], 0);
d.walk(|n| match &n.kind {
NodeKind::Text(t) => texts.push((t.plain_text(), n.appearance.effects.clone())),
NodeKind::Path { .. } => paths += 1,
_ => {}
});
(texts, paths)
};
for (name, keys, id, param, want) in [
("Adobe Twirl", "45 /Real (angle) ,\n", "distort.twist", "angle", json!(45.0)),
(
"Adobe Transform",
"5 /Real (moveH_Pts) ,\n-7 /Real (moveV_Pts) ,\n0 /Bool (transformPatterns) ,\n",
"distort.transform",
"moveV",
json!(7.0),
),
(
"Adobe Scribble and Tweak",
"30 /Real (horz) ,\n20 /Real (vert) ,\n0 /Bool (anch) ,\n0 /Bool (in) ,\n0 /Bool (out) ,\n",
"distort.tweak",
"h",
json!(30.0),
),
("Adobe Punk and Bloat", "-50 /Real (d_factor) ,\n", "distort.puckerBloat", "amount", json!(-50.0)),
("Adobe Zigzag", "3 /Real (amount) ,\n1 /Real (absoluteness) ,\n7 /Real (ridges) ,\n", "distort.zigZag", "ridges", json!(7)),
("Adobe Free Distort", "", "distort.freeDistort", "corners", json!([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])),
] {
let (texts, paths) = open(name, keys);
let [(text, effects)] = texts.as_slice() else { panic!("{name}: {texts:?}") };
assert_eq!((text.as_str(), paths), ("Bang", 0), "{name}");
let [e] = effects.as_slice() else { panic!("{name}: {effects:?}") };
assert_eq!((e.id.as_str(), &e.params[param]), (id, &want), "{name}");
}
// An angle that isn't a number: the drawing as paths, and no effect on the type.
let (texts, paths) = open("Adobe Twirl", "NaN /Real (angle) ,\n");
assert_eq!(paths, 1, "{texts:?}");
assert!(texts.iter().all(|(_, effects)| effects.is_empty()), "{texts:?}");
}
#[test]
fn type_with_convert_to_shape_comes_in_as_type_with_it_live() {
// GUARDS: `Adobe Shape Effects` wasn't read, and type with it came in without its art style,
File diff suppressed because one or more lines are too long