diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 21a877dcc..6e05941af 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2543,6 +2543,8 @@ fn arb_ai_token() -> impl Strategy { "1 /Bool (BSDashAdj) ,", "0.75 /Real (opac) ,", "1e308 /Real (blur) ,", + "1 /Int (csrc) ,", + "-1e308 /Real (dark) ,", "0 /Real ,", "; (BSVariableWidthTValuesArray) ,", "; (BSCenteredWidthsArray) ,", diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 37e02fc7e..e6fd8defb 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -330,9 +330,8 @@ fn style(filters: &[Filter]) -> ArtStyle { params: json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}), visible: true, }), - SHADOW if param("csrc").unwrap_or(0.0) == 0.0 => effects.push(Effect { - id: "stylize.dropShadow".into(), - params: json!({ + SHADOW => { + let mut params = json!({ "mode": mode(1), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), @@ -341,11 +340,15 @@ fn style(filters: &[Filter]) -> ArtStyle { "x": param("horz").unwrap_or(7.0), "y": param("vert").unwrap_or(7.0), "color": color, - }), - visible: true, - }), - // A shadow darkening the object's own colors is not read as Drop Shadow's Darkness. - SHADOW => plain = false, + }); + // A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as + // Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite. + if param("csrc").unwrap_or(0.0) != 0.0 { + params["shade"] = json!("darkness"); + params["darkness"] = json!(param("dark").filter(|v| v.is_finite()).map_or(100.0, |v| v.clamp(0.0, 100.0))); + } + effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true }); + } _ => {} } } @@ -393,6 +396,50 @@ mod tests { assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0))); } + /// The style `Shade`: a Drop Shadow whose dictionary holds `keys`. + fn shadow_style(keys: &str) -> ArtStyle { + let data = format!( + "/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n;\n" + ); + art_styles(data.as_bytes()).remove("Shade").unwrap_or_default() + } + + #[test] + fn a_drop_shadow_with_darkness_reads_its_darkness() { + // GUARDS: a `csrc` other than 0 made the style not plain, and the object came in as its drawn look. + let style = shadow_style("1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n"); + assert!(style.plain, "{style:?}"); + let [shadow] = style.effects.as_slice() else { panic!("{style:?}") }; + let p = &shadow.params; + assert_eq!( + (shadow.id.as_str(), &p["shade"], &p["darkness"], &p["opacity"], &p["blur"]), + ("stylize.dropShadow", &json!("darkness"), &json!(50.0), &json!(75.0), &json!(10.0)) + ); + } + + #[test] + fn a_drop_shadow_darkness_is_100_percent_when_missing_and_kept_within_0_to_100() { + for (dark, want) in [("", 100.0), ("(fifty) /Real (dark) ,\n", 100.0), ("250 /Real (dark) ,\n", 100.0), ("-5 /Real (dark) ,\n", 0.0)] { + let style = shadow_style(&format!("1 /Int (csrc) ,\n{dark}")); + assert!(style.plain, "{dark:?}: {style:?}"); + let [shadow] = style.effects.as_slice() else { panic!("{dark:?}: {style:?}") }; + assert_eq!((&shadow.params["shade"], &shadow.params["darkness"]), (&json!("darkness"), &json!(want)), "{dark:?}"); + } + } + + #[test] + fn a_drop_shadow_with_color_keeps_its_params() { + // GUARDS: a `csrc` of 0, or none, is Color; its shadow has neither `shade` nor `darkness`. + for csrc in ["", "0 /Int (csrc) ,\n50 /Real (dark) ,\n"] { + let style = shadow_style(&format!("2 /Int (blnd) ,\n0.5 /Real (opac) ,\n{csrc}")); + assert!(style.plain, "{csrc:?}: {style:?}"); + let [shadow] = style.effects.as_slice() else { panic!("{csrc:?}: {style:?}") }; + let want = json!({"mode": "Screen", "opacity": 50.0, "blur": 10.0, "x": 7.0, "y": 7.0, "color": vectorcraft_color::Color::BLACK}); + assert_eq!(shadow.params, want, "{csrc:?}"); + } + } + #[test] fn hostile_dash_offsets_and_width_profiles_are_tamed() { let mut f = Filter::default(); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 404c216db..e8c901bad 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1176,6 +1176,26 @@ fn a_styled_object_inside_a_drawn_look_survives_it() { assert_eq!((shadow.id.as_str(), shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), ("stylize.dropShadow", Some(3.0), Some(4.0))); } +/// An object whose style's Drop Shadow has Darkness (`csrc` 1, `dark` 50) comes in as itself with +/// that shadow, not as its drawn look. +#[test] +fn an_object_with_a_darkness_shadow_comes_in_as_itself() { + let styles = ART_STYLES.replace("3 /Real (horz) ,\n", "1 /Int (csrc) ,\n50 /Real (dark) ,\n3 /Real (horz) ,\n"); + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let r = read_ok(&format!("{styles}{}", layer(&art_))); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + let [shadow] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance.effects) }; + assert_eq!( + (shadow.id.as_str(), shadow.params["shade"].as_str(), shadow.params["darkness"].as_f64(), shadow.params["x"].as_f64()), + ("stylize.dropShadow", Some("darkness"), Some(50.0), Some(3.0)) + ); +} + /// A styled object read in place of its drawn look keeps the transparency written on the look (the /// object's own, after the look's group ends): here 60 % in Multiply. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2285ef246..dec15fa24 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -683,7 +683,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, Document Setup's substitution highlights, a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). -- **Drop Shadow's Darkness:** Effect › Stylize › Drop Shadow… has Color and Darkness as radio buttons, each with its field, and the Appearance panel's inline editor has the two radio buttons in its Shade row (`shade`: `color` or `darkness`; `darkness`: 0 to 100%, 100 by default). Color is the default, and a shadow saved without these keys draws as before. With Darkness the shadow is the object's own colors with black added: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K), and in an RGB document each channel is multiplied by 1 − darkness. Darkness 0% gives the object's color; 100% gives black in an RGB document and keeps the colors under 100% black in a CMYK one. SVG export writes the object blurred, moved and darkened with `feComponentTransfer` (screen colors, in a CMYK document too), and SVG import reads it back; CSS export leaves the shadow out and marks the object unsupported. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 428 (PDF page 434). +- **Drop Shadow's Darkness:** Effect › Stylize › Drop Shadow… has Color and Darkness as radio buttons, each with its field, and the Appearance panel's inline editor has the two radio buttons in its Shade row (`shade`: `color` or `darkness`; `darkness`: 0 to 100%, 100 by default). Color is the default, and a shadow saved without these keys draws as before. With Darkness the shadow is the object's own colors with black added: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K), and in an RGB document each channel is multiplied by 1 − darkness. Darkness 0% gives the object's color; 100% gives black in an RGB document and keeps the colors under 100% black in a CMYK one. SVG export writes the object blurred, moved and darkened with `feComponentTransfer` (screen colors, in a CMYK document too), and SVG import reads it back; CSS export leaves the shadow out and marks the object unsupported. When a `.ai`, EPS or PDF opens from its editing data, a Drop Shadow with Darkness in an object's art style is read as one with Color is, with the shadow's Darkness percentage (100% when the style gives none). Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 428 (PDF page 434). - **Live objects after document changes:** a blend, envelope or repeat, with or without an effect of its own, is evaluated again when one of these changes: the tiling of a pattern whose tiles an envelope bends (Distort Pattern Fills), a symbol shown inside a symbol, a pattern or an opacity mask, the fonts of its type, a placed document's file once the file is read, and the color settings. A gradient mesh, with or without an effect of its own, is colored again when the color settings change.