mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 18:54:57 +00:00
.ai save: the editing copy keeps graphic styles and links to them
The editing copy of a PDF-compatible .ai left out the Graphic Styles panel's styles and objects' links to them, with a warning. They are now written as art styles: - The panel's styles come first in %AI9_BeginArtStyles and are listed in its order in %AI9_BeginArtStyleList after the palette, with their fills, strokes, transparency and effects. Fills and strokes may have transparency and effects of their own, and a style may also hold Warp, Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag or Gaussian Blur. A style of an object's own holds the same effects as before. - A path, compound path or group linked to a graphic style names it when it has the style's appearance, the style is written, and the reader reads that style on that object. Objects linked to a new document's styles keep their link: the panel is written for them too. - The writer reads each style back with the reader before it writes it. A style with gradient or pattern fills, with anything the reader does not read back, with a name another style has, with a name over 1,024 bytes or past 4,096 styles is left out with a warning. Other links are left out with a warning, and a panel with no style written warns that it opens with a new document's styles. No graphic style is written on a large canvas, with a warning. Graphic styles: https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html Tests: the round trip of the panel's styles and of links; each effect a style can hold; the forms written; the list's limits; duplicate names; an empty panel; links left out; a style's opacity at the written precision; a large canvas; Twist and Transform on an object with no link; a link to a style whose Transform moves the outline, with and without an opacity mask; the reader's reading of warp numbers; fuzz cases for damaged written styles. Docs: docs/file-format-parity.md.
This commit is contained in:
@@ -3708,13 +3708,13 @@ proptest! {
|
||||
const STYLES_FUZZ_CHARS: usize = 64_000;
|
||||
|
||||
/// The editing data VectorCraft writes for a document with a drop shadow on a path, a gradient fill
|
||||
/// and a width profile on others, a stroked even-odd path with an opacity mask and Pathfinder › Add
|
||||
/// on a group.
|
||||
/// and a width profile on others, a stroked even-odd path with an opacity mask, Pathfinder › Add on
|
||||
/// a group and a graphic style of its own with effects on one of its fills.
|
||||
fn styles_text() -> &'static str {
|
||||
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
TEXT.get_or_init(|| {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, GraphicStyle, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_geom::{FillRule, Rect, shapes};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
@@ -3757,6 +3757,10 @@ fn styles_text() -> &'static str {
|
||||
let mut merged = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]);
|
||||
merged.appearance = Appearance { effects: vec![fx("pathfinder.add")], ..Appearance::default() }.into();
|
||||
doc.insert(Some(layer), usize::MAX, merged).unwrap();
|
||||
doc.graphic_styles.push(GraphicStyle::new("Shaded", ap));
|
||||
let chained = FillLayer { opacity: 0.5, effects: vec![fx("distort.twist")], ..FillLayer::new(Paint::solid(Color::WHITE)) };
|
||||
let chained = Appearance { items: vec![AppearanceItem::Fill(chained)], ..Appearance::default() };
|
||||
doc.graphic_styles.push(GraphicStyle::new("Chained", chained));
|
||||
let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap();
|
||||
String::from_utf8(out.program).unwrap()
|
||||
})
|
||||
@@ -3768,14 +3772,25 @@ fn styles_text() -> &'static str {
|
||||
fn the_written_styles_fuzz_data_opens_live() {
|
||||
let text = styles_text();
|
||||
assert!(text.chars().count() < STYLES_FUZZ_CHARS, "{}", text.chars().count());
|
||||
for mark in ["%AI9_BeginArtStyles", "%AI5_BeginLayer", "\n6 () XW", "\n1 (Anon", "(BSLeftWidthsArray)", "0 /Bool (UseEvenOdd)", "Bb"] {
|
||||
assert!(text.contains(mark), "{mark}");
|
||||
for mark in [
|
||||
"%AI9_BeginArtStyles",
|
||||
"%AI9_BeginArtStyleList",
|
||||
"%AI5_BeginLayer",
|
||||
"\n6 () XW",
|
||||
"\n1 (Anon",
|
||||
"(BSLeftWidthsArray)",
|
||||
"0 /Bool (UseEvenOdd)",
|
||||
"(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter",
|
||||
"Bb",
|
||||
] {
|
||||
assert!(text.replace("\r\n", "\n").contains(mark), "{mark}");
|
||||
}
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat();
|
||||
let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
|
||||
assert!(layered, "{warnings:?}");
|
||||
let art = d.layers[0].children().unwrap();
|
||||
assert_eq!(art.iter().map(|n| n.appearance.effects.len()).collect::<Vec<_>>(), [1, 1, 1, 1, 1]);
|
||||
assert!(d.graphic_style("Shaded").is_some() && d.graphic_style("Chained").is_some());
|
||||
}
|
||||
|
||||
/// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the
|
||||
|
||||
@@ -101,16 +101,17 @@
|
||||
//! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and
|
||||
//! a gradient's take the art to the canvas's space turned y up about art space's origin there.
|
||||
//! - Art styles, as the reader reads them (`style.rs`, `read/ops.rs`): a path, compound path or
|
||||
//! group with live effects is written as its drawn look (`u`, its vector art as its geometry
|
||||
//! effects leave it, without shadows, glows or feathers, `U`, its transparency, `0 0 Xd`,
|
||||
//! `6 () XW`), then the object itself on `%_` lines (its points painted
|
||||
//! group with live effects or linked to a graphic style is written as its drawn look (`u`, its
|
||||
//! vector art as its geometry effects leave it, without shadows, glows or feathers, `U`, its
|
||||
//! transparency, `0 0 Xd`, `6 () XW`), then the object itself on `%_` lines (its points painted
|
||||
//! with nothing, or with its gradient fill; a group with its members) with its name and opacity
|
||||
//! mask, then `1 (style) XW`. The styles are written in a non-printing block after the symbols
|
||||
//! (`%AI9_BeginArtStyles`, each `/KnownStyle :` with its name and a filter tree: a chain of the
|
||||
//! stack of fills and strokes with the contents among them, the effects and the blend filter). A
|
||||
//! fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that fills by
|
||||
//! the even-odd rule is written as its drawn look. A style is written only when the reader reads it
|
||||
//! back as the object's appearance.
|
||||
//! stack of fills and strokes with the contents among them, the effects and the blend filter), the
|
||||
//! Graphic Styles panel's first; the panel's order follows the palette (`%AI9_BeginArtStyleList`).
|
||||
//! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that
|
||||
//! fills by the even-odd rule is written as its drawn look. A style is written only when the
|
||||
//! reader reads it back as the object's appearance (or the graphic style's).
|
||||
//!
|
||||
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
|
||||
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
|
||||
@@ -145,8 +146,9 @@
|
||||
//! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as
|
||||
//! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on
|
||||
//! layers and opacity masks nested too deep, and, on objects written without an art style, live
|
||||
//! effects other than geometry effects left out; graphic styles, links to them and every art style
|
||||
//! on a large canvas are left out.
|
||||
//! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to
|
||||
//! graphic styles on objects not written with them, and every art style on a large canvas, are left
|
||||
//! out.
|
||||
//!
|
||||
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
|
||||
//! the version numbers of the newest files users sent ([`private_data`]).
|
||||
@@ -228,7 +230,8 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
// The objects that may keep their effects in an art style, as they are before baking (not on
|
||||
// a large canvas, whose scale the styles' strokes and effects would need).
|
||||
let styled = writer::styled_objects(doc);
|
||||
let graphic_styles = writer::has_graphic_styles(doc);
|
||||
let own_styles = doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles;
|
||||
let links = writer::style_links(doc);
|
||||
// Live geometry effects as their result, as the PDF part draws them.
|
||||
let baked = baked(doc);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
@@ -262,15 +265,16 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
w.reserve_swatch_gradients();
|
||||
if scale == 1.0 {
|
||||
w.styled = styled;
|
||||
} else if !styled.is_empty() {
|
||||
w.panel_styles();
|
||||
} else if !styled.is_empty() || own_styles || links > 0 {
|
||||
w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string());
|
||||
}
|
||||
if graphic_styles {
|
||||
w.warnings.push(writer::GRAPHIC_STYLES.to_string());
|
||||
}
|
||||
for layer in &doc.layers {
|
||||
w.layer(layer);
|
||||
}
|
||||
if scale == 1.0 {
|
||||
w.lost_links(links);
|
||||
}
|
||||
// The symbols no instance uses too; their art adds definitions.
|
||||
w.symbols();
|
||||
// The palette first: its gradient and pattern swatches add definitions.
|
||||
@@ -293,6 +297,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
w.symbol_definitions(&mut s);
|
||||
w.art_styles(&mut s);
|
||||
s.push_str(&palette);
|
||||
w.art_style_list(&mut s);
|
||||
w.symbol_list(&mut s);
|
||||
artboards(&mut s, doc, to_art, origin);
|
||||
if !w.texts.is_empty() {
|
||||
|
||||
@@ -5246,6 +5246,33 @@ fn styles_document() -> Document {
|
||||
let mut merged = group(&mut doc, vec![a, b], false);
|
||||
merged.appearance = Appearance { effects: vec![fx("pathfinder.add", serde_json::json!({}))], ..Appearance::default() }.into();
|
||||
add(&mut doc, named(merged, "Merged"));
|
||||
// A graphic style of its own, and an object linked to one.
|
||||
let mut fill_top = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9)));
|
||||
fill_top.opacity = 0.5;
|
||||
fill_top.blend = BlendMode::Screen;
|
||||
fill_top.effects = vec![fx("distort.twist", serde_json::json!({"angle": 30.0}))];
|
||||
let style_ap = Appearance {
|
||||
items: vec![
|
||||
AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.9, 0.9, 0.1)))),
|
||||
AppearanceItem::Fill(fill_top),
|
||||
AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 3.0)),
|
||||
],
|
||||
effects: vec![fx("warp.arc", serde_json::json!({"bend": 30.0})), shadow],
|
||||
// The contents over every fill and stroke.
|
||||
contents_index: Some(3),
|
||||
};
|
||||
let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", style_ap);
|
||||
layered.id = 9;
|
||||
layered.opacity = 0.8;
|
||||
doc.graphic_styles.push(layered);
|
||||
let sunshine = doc.graphic_style("Sunshine").unwrap().clone();
|
||||
let mut linked_obj = Node::path(
|
||||
doc.alloc_id(),
|
||||
shapes::rectangle(Rect::new(170.0, 130.0, 220.0, 170.0)),
|
||||
sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(),
|
||||
);
|
||||
linked_obj.extras_mut().graphic_style = Some(sunshine.id);
|
||||
add(&mut doc, named(linked_obj, "Sunny"));
|
||||
doc
|
||||
}
|
||||
|
||||
@@ -5369,12 +5396,24 @@ fn near_lines(got: &str, want: &str) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_with_live_effects_are_read_back() {
|
||||
fn objects_with_live_effects_and_graphic_styles_are_read_back() {
|
||||
let doc = styles_document();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
same_styles(&back, &doc);
|
||||
// The panel's styles, in its order, with their appearance and transparency.
|
||||
let names = |d: &Document| d.graphic_styles.iter().map(|g| g.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&back), names(&doc));
|
||||
for (g, w) in back.graphic_styles.iter().zip(doc.graphic_styles.iter()) {
|
||||
assert!(writer::same_appearance(&g.appearance, &w.appearance), "{}:\n got {:?}\n want {:?}", w.name, g.appearance, w.appearance);
|
||||
assert_eq!(
|
||||
(r3(f64::from(g.opacity)), g.blend, g.isolate, g.knockout),
|
||||
(r3(f64::from(w.opacity)), w.blend, w.isolate, w.knockout),
|
||||
"{}",
|
||||
w.name
|
||||
);
|
||||
}
|
||||
// A group's members come back as they were.
|
||||
let members = |d: &Document, name: &str| {
|
||||
let mut out = vec![];
|
||||
@@ -5386,13 +5425,39 @@ fn objects_with_live_effects_are_read_back() {
|
||||
same_lines(&members(&back, "Merged"), &members(&doc, "Merged"));
|
||||
}
|
||||
|
||||
/// In a document with only a new document's graphic styles, an object linked to one of them keeps
|
||||
/// its link: the panel's styles are written.
|
||||
#[test]
|
||||
fn a_link_to_a_built_in_graphic_style_is_read_back() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let sunshine = doc.graphic_style("Sunshine").unwrap().clone();
|
||||
let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned();
|
||||
// Without a link, a new document's styles are not written.
|
||||
let mut plain = doc.clone();
|
||||
let unlinked = Node::path(plain.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap.clone());
|
||||
plain.insert(Some(layer), usize::MAX, unlinked).unwrap();
|
||||
assert!(!program(&plain).contains("%AI9_BeginArtStyleList"));
|
||||
let mut sunny = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap);
|
||||
sunny.extras_mut().graphic_style = Some(sunshine.id);
|
||||
doc.insert(Some(layer), usize::MAX, named(sunny, "Sunny")).unwrap();
|
||||
assert_eq!(doc.graphic_styles, Document::new(1.0, 1.0).graphic_styles);
|
||||
let back = read_back(&doc);
|
||||
same_styles(&back, &doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Sunshine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_styles_are_written_in_their_forms() {
|
||||
let p = program_shown(&styles_document());
|
||||
// The styles in a non-printing block after the symbols' and before the palette.
|
||||
let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Anon 1) /Name ,\n/ActiveStyle :\n").unwrap();
|
||||
// The styles in a non-printing block after the symbols' and before the palette; the panel's in
|
||||
// its order first, then the objects' own.
|
||||
let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Default Graphic Style) /Name ,\n/ActiveStyle :\n").unwrap();
|
||||
assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}");
|
||||
assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}");
|
||||
let list = "%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n(Default Graphic Style)\n(Black Outline)\n(Heavy Ink)\n(Sunshine)\n(Layered)\n%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n";
|
||||
let at = p.find(list).unwrap();
|
||||
assert!(p.find("%AI5_EndPalette").unwrap() < at && at < p.find("%AI9_BeginDocumentData").unwrap(), "{p}");
|
||||
// A drop shadow on the object, after its stack and before its transparency.
|
||||
assert!(
|
||||
p.contains(
|
||||
@@ -5406,6 +5471,8 @@ fn art_styles_are_written_in_their_forms() {
|
||||
assert!(p.contains("U\n0 1 0 2 0 Xy\n0 0 Xd\n6 () XW\n%_0 XR\n%_0 1 0 0 0 Xy\n%_10 -10 m\n"), "{p}");
|
||||
assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Shadowed) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon 1) XW\n"), "{p}");
|
||||
assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Offset) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}");
|
||||
// The object linked to a graphic style names it.
|
||||
assert!(p.contains("%_/XMLUID : (Sunny) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Sunshine) XW\n"), "{p}");
|
||||
// A group's members on `%_` lines, then its name and style.
|
||||
assert!(p.contains("6 () XW\n%_u\n"), "{p}");
|
||||
assert!(p.contains("%_U\n%_/ArtDictionary :\n%_/XMLUID : (Merged) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}");
|
||||
@@ -5425,8 +5492,9 @@ fn art_styles_are_written_in_their_forms() {
|
||||
}
|
||||
|
||||
/// Objects whose appearance the reader wouldn't read with their style are written as their
|
||||
/// drawn look, with the warnings they were written with before; and a document without effects has
|
||||
/// no art styles.
|
||||
/// drawn look, with the warnings they were written with before; graphic styles it wouldn't read
|
||||
/// are left out with a warning; and a document without effects or styles of its own has no art
|
||||
/// styles.
|
||||
#[test]
|
||||
fn appearances_the_reader_wouldnt_read_are_their_drawn_look() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
@@ -5454,10 +5522,41 @@ fn appearances_the_reader_wouldnt_read_are_their_drawn_look() {
|
||||
assert!(!p.contains("%AI9_BeginArtStyles") && !p.lines().any(|l| l.starts_with("1 (")), "{p}");
|
||||
let back = read_back(&doc);
|
||||
assert!(back.layers[0].children().unwrap().iter().all(|n| n.appearance.effects.is_empty()));
|
||||
// A graphic style with a gradient: left out, the others listed.
|
||||
let ramp = Gradient { stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)], ..Gradient::default() };
|
||||
let graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0);
|
||||
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", graded));
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
let p = String::from_utf8(out.program).unwrap().replace("\r\n", "\n");
|
||||
assert!(p.contains("(Sunshine)\n%AI9_EndArtStyleList") && !p.contains("(Graded)"), "{p}");
|
||||
// Nothing styled: no art styles.
|
||||
assert!(!program(&sample()).contains("ArtStyle"));
|
||||
}
|
||||
|
||||
/// A graphic style's opacity is read back within the written number's precision: the style and an
|
||||
/// object's link to it stay.
|
||||
#[test]
|
||||
fn a_graphic_styles_opacity_is_compared_at_the_written_precision() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let ap = Appearance::basic(solid(Color::BLACK), Paint::None, 1.0);
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Fine", ap.clone());
|
||||
g.id = 1;
|
||||
g.opacity = 0.123456;
|
||||
*doc.graphic_styles = vec![g];
|
||||
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap);
|
||||
n.opacity = 0.123456;
|
||||
n.extras_mut().graphic_style = Some(1);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(back.graphic_styles.len(), 1);
|
||||
assert!((back.graphic_styles[0].opacity - 0.123456).abs() < 1e-3);
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, Some(back.graphic_styles[0].id));
|
||||
}
|
||||
|
||||
/// A compound shape draws as one area, which an art style's object wouldn't keep: it and a group
|
||||
/// holding one are written as their drawn look, with a warning.
|
||||
#[test]
|
||||
@@ -5488,6 +5587,45 @@ fn compound_shapes_are_not_written_with_an_art_style() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Graphic styles the reader's style list wouldn't hold (a name over its limit, styles past its
|
||||
/// count) are left out with a warning, and an object linked to one has no link.
|
||||
#[test]
|
||||
fn graphic_styles_past_the_lists_limits_are_left_out() {
|
||||
let plain = || Appearance::basic(solid(Color::BLACK), Paint::None, 1.0);
|
||||
let styled = |name: String, id: u32| {
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new(name, plain());
|
||||
g.id = id;
|
||||
g
|
||||
};
|
||||
// A name one byte over the limit, among others.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let long = "n".repeat(1025);
|
||||
*doc.graphic_styles = vec![styled("Before".into(), 1), styled(long.clone(), 2), styled("After".into(), 3)];
|
||||
let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), plain());
|
||||
linked.extras_mut().graphic_style = Some(2);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, linked).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
assert!(!shown(&out.program).contains(&long));
|
||||
let back = read_back(&doc);
|
||||
let names: Vec<&str> = back.graphic_styles.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(names, ["Before", "After"]);
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None);
|
||||
// A name at the limit is kept.
|
||||
let at_limit = "n".repeat(1024);
|
||||
doc.graphic_styles[1].name = at_limit.clone();
|
||||
assert!(read_back(&doc).graphic_styles.iter().any(|g| g.name == at_limit));
|
||||
// Styles past the count.
|
||||
let mut many = Document::new(200.0, 100.0);
|
||||
*many.graphic_styles = (1..=4097).map(|i| styled(format!("Style {i}"), i)).collect();
|
||||
let out = export_ai_editing(&many, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
let back = read_back(&many);
|
||||
assert_eq!(back.graphic_styles.len(), 4096);
|
||||
assert_eq!(back.graphic_styles.last().map(|g| g.name.as_str()), Some("Style 4096"));
|
||||
}
|
||||
|
||||
/// A member of a group whose Transform effect moves it is written where baking put it, not with
|
||||
/// its own effects where it was.
|
||||
#[test]
|
||||
@@ -5620,7 +5758,8 @@ fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc<Node>, Vec<String>
|
||||
|
||||
/// A filled path or compound path with live effects that fills by the even-odd rule is written as its
|
||||
/// drawn look with a warning, since a style's fill fills by the non-zero rule. A stroke-only object
|
||||
/// keeps its style and its own fill rule.
|
||||
/// keeps its style and its own fill rule. A filled even-odd object linked to a graphic style isn't
|
||||
/// written with that style, and its link is left out with a warning.
|
||||
#[test]
|
||||
fn a_filled_even_odd_object_is_written_as_its_drawn_look() {
|
||||
let mut doc = Document::new(300.0, 100.0);
|
||||
@@ -5649,9 +5788,28 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() {
|
||||
let back = read_back(&doc);
|
||||
same_styles(&back, &doc);
|
||||
assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. }));
|
||||
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let ap = Appearance::basic(black_fill(), Paint::None, 0.0);
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone());
|
||||
g.id = 7;
|
||||
doc.graphic_styles.push(g);
|
||||
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap);
|
||||
n.extras_mut().graphic_style = Some(7);
|
||||
if let NodeKind::Path { rule, .. } = &mut n.kind {
|
||||
*rule = FillRule::EvenOdd;
|
||||
}
|
||||
put(&mut doc, n);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert!(matches!(n.kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }), "{n:?}");
|
||||
assert_eq!(n.extras().graphic_style, None);
|
||||
}
|
||||
|
||||
/// A compound path filled by the non-zero rule keeps its effects and its gradient fill.
|
||||
/// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to
|
||||
/// a graphic style.
|
||||
#[test]
|
||||
fn a_compound_path_keeps_its_style() {
|
||||
let mut doc = Document::new(300.0, 100.0);
|
||||
@@ -5669,8 +5827,16 @@ fn a_compound_path_keeps_its_style() {
|
||||
let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(graded, Paint::None, 0.0) };
|
||||
let n = named(non_zero(ring(&mut doc, ap)), "Graded");
|
||||
put(&mut doc, n);
|
||||
let sunshine = doc.graphic_style("Sunshine").unwrap().clone();
|
||||
let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned();
|
||||
let mut linked_ring = named(ring(&mut doc, ap), "Linked");
|
||||
if let NodeKind::Compound { rule, .. } = &mut linked_ring.kind {
|
||||
*rule = FillRule::NonZero;
|
||||
}
|
||||
linked_ring.extras_mut().graphic_style = Some(sunshine.id);
|
||||
put(&mut doc, linked_ring);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
same_styles(&back, &doc);
|
||||
assert!(back.layers[0].children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Compound { .. })));
|
||||
@@ -5696,36 +5862,136 @@ fn a_group_holding_an_image_is_written_as_its_drawn_look() {
|
||||
assert!(im.link.is_some());
|
||||
}
|
||||
|
||||
/// Graphic styles and links to them are left out with a warning: an object linked to a style
|
||||
/// keeps its effects in a style of its own. A new document's styles without a link don't warn.
|
||||
/// A link to a graphic style that isn't written warns; one that is written doesn't.
|
||||
#[test]
|
||||
fn graphic_styles_and_links_are_left_out_with_a_warning() {
|
||||
let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::GRAPHIC_STYLES);
|
||||
assert!(!warns(&Document::new(200.0, 100.0)));
|
||||
// A style of the document's own.
|
||||
let mut own = Document::new(200.0, 100.0);
|
||||
own.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0)));
|
||||
assert!(warns(&own));
|
||||
assert!(!shown(&export_ai_editing(&own, &opts()).unwrap().program).contains("ArtStyle"));
|
||||
assert_eq!(read_back(&own).graphic_style("Mine"), None);
|
||||
// A link to a new document's style, on an object with an effect.
|
||||
let mut linked = Document::new(200.0, 100.0);
|
||||
let id = linked.graphic_style("Sunshine").unwrap().id;
|
||||
let mut n = plain_rect(&mut linked, "Linked", vec![drop_shadow()]);
|
||||
n.extras_mut().graphic_style = Some(id);
|
||||
put(&mut linked, n);
|
||||
assert!(warns(&linked));
|
||||
let back = read_back(&linked);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(n.extras().graphic_style, None);
|
||||
assert_eq!(n.appearance.effects.len(), 1);
|
||||
fn a_link_left_out_warns() {
|
||||
let warns = |make: &dyn Fn(&mut Document, u32) -> Node| {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let ap = Appearance {
|
||||
items: vec![AppearanceItem::Fill(FillLayer::new(black_fill())), AppearanceItem::Fill(FillLayer::new(solid(Color::WHITE)))],
|
||||
..Appearance::default()
|
||||
};
|
||||
let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", ap);
|
||||
layered.id = 7;
|
||||
doc.graphic_styles.push(layered);
|
||||
let mut plain = vectorcraft_doc::GraphicStyle::new("Plain", Appearance::basic(black_fill(), Paint::None, 0.0));
|
||||
plain.id = 8;
|
||||
doc.graphic_styles.push(plain);
|
||||
let n = make(&mut doc, 7);
|
||||
put(&mut doc, n);
|
||||
let warned = export_ai_editing(&doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::STYLE_LINKS);
|
||||
(warned, read_back(&doc))
|
||||
};
|
||||
let linked = |mut n: Node, id: u32| {
|
||||
n.extras_mut().graphic_style = Some(id);
|
||||
n
|
||||
};
|
||||
// A style of two fills: a path isn't read with it.
|
||||
let (warned, back) = warns(&|doc, id| {
|
||||
let ap = doc.graphic_style_by_id(id).unwrap().appearance.clone();
|
||||
linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap), id)
|
||||
});
|
||||
assert!(warned);
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None);
|
||||
assert!(back.graphic_style("Layered").is_some());
|
||||
// An image, a symbol instance and a knockout shape are never written with a style.
|
||||
let (warned, _) = warns(&|doc, _| {
|
||||
let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK);
|
||||
doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
|
||||
linked(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }), 8)
|
||||
});
|
||||
assert!(warned);
|
||||
let (warned, _) = warns(&|doc, _| {
|
||||
let mut n = plain_rect(doc, "Out", vec![]);
|
||||
n.knockout_shape = true;
|
||||
linked(n, 8)
|
||||
});
|
||||
assert!(warned);
|
||||
// A group with a member the style can't hold.
|
||||
let (warned, _) = warns(&|doc, _| {
|
||||
let member = plain_rect(doc, "Member", vec![drop_shadow()]);
|
||||
linked(group(doc, vec![member], false), 8)
|
||||
});
|
||||
assert!(warned);
|
||||
// A group with an effect and a member linked to a style: the group keeps its effect, and the
|
||||
// member's link is left out.
|
||||
let (warned, back) = warns(&|doc, _| {
|
||||
let member = linked(plain_rect(doc, "Member", vec![]), 8);
|
||||
let mut g = group(doc, vec![member], false);
|
||||
g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into();
|
||||
g
|
||||
});
|
||||
assert!(warned);
|
||||
let g = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(g.appearance.effects.len(), 1, "{g:?}");
|
||||
assert_eq!(g.children().unwrap()[0].extras().graphic_style, None);
|
||||
// A group whose link isn't written: its member keeps its own.
|
||||
let (warned, back) = warns(&|doc, id| {
|
||||
let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone();
|
||||
let member = linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1);
|
||||
linked(group(doc, vec![member], false), id)
|
||||
});
|
||||
assert!(warned);
|
||||
let g = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(g.extras().graphic_style, None);
|
||||
let member = g.children().unwrap()[0].extras().graphic_style.unwrap();
|
||||
assert_eq!(back.graphic_style_by_id(member).map(|s| s.name.as_str()), Some("Plain"));
|
||||
// A link in a symbol's art.
|
||||
let mut in_symbol = Document::new(200.0, 100.0);
|
||||
let id = in_symbol.graphic_style("Sunshine").unwrap().id;
|
||||
let mut art = square(&mut in_symbol, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK);
|
||||
art.extras_mut().graphic_style = Some(id);
|
||||
in_symbol.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
|
||||
assert!(warns(&in_symbol));
|
||||
let (warned, _) = warns(&|doc, _| {
|
||||
let art = linked(square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK), 8);
|
||||
doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
|
||||
Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) })
|
||||
});
|
||||
assert!(warned);
|
||||
// A link that is kept.
|
||||
let (warned, back) = warns(&|doc, id| {
|
||||
let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone();
|
||||
linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1)
|
||||
});
|
||||
assert!(!warned);
|
||||
let id = back.layers[0].children().unwrap()[0].extras().graphic_style.unwrap();
|
||||
assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Plain"));
|
||||
}
|
||||
|
||||
/// A panel with no style written warns that a new document's styles come back.
|
||||
#[test]
|
||||
fn an_empty_graphic_styles_panel_warns() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
doc.graphic_styles.clear();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings);
|
||||
assert!(!shown(&out.program).contains("ArtStyle"));
|
||||
assert_eq!(read_back(&doc).graphic_styles, Document::new(1.0, 1.0).graphic_styles);
|
||||
// Every style left out.
|
||||
let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)];
|
||||
let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", Appearance::basic(graded, Paint::None, 0.0)));
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY) && out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
|
||||
// A new document's styles aren't written and don't warn.
|
||||
let out = export_ai_editing(&Document::new(200.0, 100.0), &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
/// A second style of one name is left out with a warning, and an object linked to it has no link.
|
||||
#[test]
|
||||
fn a_graphic_style_of_a_name_another_has_is_left_out() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let style = |id: u32, c: Color| {
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Twin", Appearance::basic(solid(c), Paint::None, 0.0));
|
||||
g.id = id;
|
||||
g
|
||||
};
|
||||
*doc.graphic_styles = vec![style(1, Color::BLACK), style(2, Color::WHITE)];
|
||||
let mut n = styled_rect(&mut doc, "Second", Rect::new(10.0, 10.0, 50.0, 40.0), solid(Color::WHITE), Paint::None, vec![]);
|
||||
n.extras_mut().graphic_style = Some(2);
|
||||
put(&mut doc, n);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES) && out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(back.graphic_styles.len(), 1);
|
||||
assert_eq!(back.graphic_styles[0].appearance.fill().and_then(|f| f.paint.color()), Some(Color::BLACK));
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None);
|
||||
}
|
||||
|
||||
/// A path or compound path with no points writes nothing, with or without a style; a group with no
|
||||
@@ -5850,6 +6116,93 @@ fn every_effect_setting_written_on_an_object_is_read_back() {
|
||||
assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}");
|
||||
}
|
||||
|
||||
/// Each effect a graphic style can hold, in a style of its own, reads back with its settings, whatever
|
||||
/// the writer's conversions to the file's units.
|
||||
#[test]
|
||||
fn every_effect_a_graphic_style_holds_is_read_back() {
|
||||
use serde_json::json;
|
||||
let mut cases = vec![
|
||||
(
|
||||
"distort.transform",
|
||||
json!({"scaleH": 50.0, "scaleV": 150.0, "moveH": 12.5, "moveV": -7.5, "rotate": 30.0, "copies": 2, "reference": 8, "reflectX": true, "objects": false, "strokes": true, "patterns": true}),
|
||||
),
|
||||
("distort.freeDistort", json!({"corners": [[0.1, 0.05], [0.9, 0.1], [1.0, 1.0], [0.0, 0.8]]})),
|
||||
("distort.puckerBloat", json!({"amount": -40.0})),
|
||||
("distort.puckerBloat", json!({"amount": 60.0})),
|
||||
("distort.twist", json!({"angle": -45.0})),
|
||||
("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})),
|
||||
("distort.zigZag", json!({"size": 25.0, "relative": true, "ridges": 3, "points": "smooth"})),
|
||||
("blur.gaussian", json!({"radius": 2.5})),
|
||||
];
|
||||
let warps = [
|
||||
"arc",
|
||||
"arcLower",
|
||||
"arcUpper",
|
||||
"arch",
|
||||
"bulge",
|
||||
"shellLower",
|
||||
"shellUpper",
|
||||
"flag",
|
||||
"wave",
|
||||
"fish",
|
||||
"rise",
|
||||
"fisheye",
|
||||
"inflate",
|
||||
"squeeze",
|
||||
"twist",
|
||||
];
|
||||
let ids: Vec<String> = warps.iter().map(|w| format!("warp.{w}")).collect();
|
||||
for id in &ids {
|
||||
cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"})));
|
||||
cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
|
||||
}
|
||||
for (id, params) in cases {
|
||||
let effect = fx(id, params.clone());
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new(
|
||||
"Held",
|
||||
Appearance { effects: vec![effect.clone()], ..Appearance::basic(black_fill(), Paint::None, 0.0) },
|
||||
);
|
||||
g.id = 3;
|
||||
*doc.graphic_styles = vec![g];
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{id} {params}: {:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let got = &back.graphic_styles[0].appearance.effects;
|
||||
assert_eq!(got.len(), 1, "{id} {params}");
|
||||
assert_eq!(got[0].id, effect.id);
|
||||
let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params));
|
||||
for (key, value) in w.as_object().unwrap() {
|
||||
// Random isn't written, and a style with it isn't read; its seed is the default.
|
||||
let read = g.get(key).unwrap_or(&serde_json::Value::Null);
|
||||
match value {
|
||||
serde_json::Value::Array(rows) => {
|
||||
let got = read.as_array().unwrap();
|
||||
assert_eq!(got.len(), rows.len(), "{id}");
|
||||
for (r, w) in got.iter().zip(rows) {
|
||||
for (r, w) in r.as_array().unwrap().iter().zip(w.as_array().unwrap()) {
|
||||
assert!((r.as_f64().unwrap() - w.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Number(v) => assert!((read.as_f64().unwrap() - v.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {v}"),
|
||||
serde_json::Value::String(v) => assert_eq!(read.as_str().map(str::to_ascii_lowercase), Some(v.to_ascii_lowercase()), "{id} {key}"),
|
||||
_ => assert_eq!(read, value, "{id} {key}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let effects = vec![
|
||||
fx("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})),
|
||||
fx("distort.transform", json!({"moveV": -7.5, "rotate": 30.0})),
|
||||
];
|
||||
doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Forms", Appearance { effects, ..Appearance::default() }));
|
||||
let p = program_shown(&doc);
|
||||
assert!(p.contains("7.5 /Real (amount) ,") && p.contains("0 /Real (roundness) ,"), "{p}");
|
||||
assert!(p.contains("7.5 /Real (moveV_Pts) ,") && p.contains("0.5236 /Real (rotate_Radians) ,"), "{p}");
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -5991,6 +6344,60 @@ fn appearances_written_as_their_drawn_look() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A link to a graphic style whose Transform moves the outline is kept on a path the reader reads
|
||||
/// that style on, and left out with a warning on a path with an opacity mask, which keeps its mask.
|
||||
#[test]
|
||||
fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it() {
|
||||
let make = |masked: bool| {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let moved = fx("distort.transform", serde_json::json!({"copies": 0, "scaleH": 50.0}));
|
||||
let ap = Appearance { effects: vec![moved], ..Appearance::basic(black_fill(), Paint::None, 0.0) };
|
||||
let mut g = vectorcraft_doc::GraphicStyle::new("Moved", ap.clone());
|
||||
g.id = 9;
|
||||
doc.graphic_styles.push(g);
|
||||
let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap);
|
||||
n.extras_mut().graphic_style = Some(9);
|
||||
if masked {
|
||||
let art = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 25.0), Color::gray(0.5));
|
||||
n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into());
|
||||
}
|
||||
put(&mut doc, n);
|
||||
doc
|
||||
};
|
||||
let doc = make(false);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Moved"));
|
||||
|
||||
let doc = make(true);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(n.extras().graphic_style, None);
|
||||
assert!(n.extras().mask.is_some(), "{n:?}");
|
||||
}
|
||||
|
||||
/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style
|
||||
/// of its own: the object reads back without live effects.
|
||||
#[test]
|
||||
fn distort_effects_on_an_unlinked_object_write_no_style() {
|
||||
let cases = [fx("distort.twist", serde_json::json!({"angle": 30.0})), fx("distort.transform", serde_json::json!({"copies": 0, "moveH": 5.0}))];
|
||||
for e in cases {
|
||||
let what = e.id.clone();
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let n = plain_rect(&mut doc, "D", vec![e]);
|
||||
put(&mut doc, n);
|
||||
let p = shown(&export_ai_editing(&doc, &opts()).unwrap().program);
|
||||
assert!(!p.contains("%AI9_BeginArtStyles") && !p.contains("(Anon 1) XW"), "{what}:\n{p}");
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert!(n.appearance.effects.is_empty(), "{what}: {n:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient fill that fits its object is placed on the outline its geometry effects draw: an
|
||||
/// object with Offset Path reads back with the gradient a plain object of the drawn size has.
|
||||
#[test]
|
||||
@@ -6289,14 +6696,26 @@ fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() {
|
||||
}
|
||||
}
|
||||
|
||||
/// On a large canvas an object with an effect warns and comes back as its drawn look at its place.
|
||||
/// On a large canvas a document with only graphic styles, an object linked to one and an object with
|
||||
/// an effect all warn, and the object comes back as its drawn look at its place.
|
||||
#[test]
|
||||
fn a_large_canvas_writes_every_object_as_its_look() {
|
||||
let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES);
|
||||
let mut styles_only = Document::new(20000.0, 100.0);
|
||||
styles_only.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0)));
|
||||
assert!(warns(&styles_only));
|
||||
assert_eq!(read_back(&styles_only).graphic_style("Mine"), None);
|
||||
let mut plain = Document::new(20000.0, 100.0);
|
||||
let n = plain_rect(&mut plain, "None", vec![]);
|
||||
put(&mut plain, n);
|
||||
assert!(!warns(&plain));
|
||||
let mut linked_doc = Document::new(20000.0, 100.0);
|
||||
let id = linked_doc.graphic_style("Sunshine").unwrap().id;
|
||||
let mut n = plain_rect(&mut linked_doc, "Linked", vec![]);
|
||||
n.extras_mut().graphic_style = Some(id);
|
||||
put(&mut linked_doc, n);
|
||||
assert!(warns(&linked_doc));
|
||||
assert_eq!(read_back(&linked_doc).layers[0].children().unwrap()[0].extras().graphic_style, None);
|
||||
let mut shaded = Document::new(20000.0, 100.0);
|
||||
let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]);
|
||||
put(&mut shaded, n);
|
||||
|
||||
@@ -21,9 +21,9 @@ mod styles;
|
||||
mod symbols;
|
||||
mod texts;
|
||||
|
||||
pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects};
|
||||
#[cfg(test)]
|
||||
pub(super) use styles::same_appearance;
|
||||
pub(super) use styles::{GRAPHIC_STYLES, LARGE_CANVAS_STYLES, has_graphic_styles, styled_objects};
|
||||
pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance};
|
||||
|
||||
pub(super) use images::PAYLOAD;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
//! Art styles: objects with live effects a plain path can't hold, written as their drawn look, the
|
||||
//! object itself on `%_` lines and `1 (style) XW`, and the styles they name in
|
||||
//! `%AI9_BeginArtStyles` (see [`crate::ai_editing`] for the form). Each style is read back before it
|
||||
//! is written ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of
|
||||
//! a graphic style, with the object's own gradient): one that reads back differently is not
|
||||
//! written, and the object is written as its drawn look alone.
|
||||
//! Art styles: objects whose appearance a plain path can't hold, written as their drawn look, the
|
||||
//! object itself on `%_` lines and `1 (style) XW`; the styles they name and the Graphic Styles
|
||||
//! panel's styles in `%AI9_BeginArtStyles`, and the panel's order in `%AI9_BeginArtStyleList` (see
|
||||
//! [`crate::ai_editing`] for the form). Each style is read back before it is written
|
||||
//! ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of a graphic
|
||||
//! style, with the object's own gradient): one that reads back differently is not written, and the
|
||||
//! object is written as its drawn look alone.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, GraphicStyle, Knockout, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, Rect, Shape};
|
||||
|
||||
use super::{Writer, Xy, blend_code, compound_path, hide};
|
||||
@@ -19,34 +20,36 @@ use crate::ai_ops;
|
||||
use crate::ps;
|
||||
|
||||
pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str =
|
||||
"on a large canvas, objects with live effects are written as their drawn look in the editing data for other apps";
|
||||
pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps";
|
||||
"on a large canvas, objects with live effects are written as their drawn look and graphic styles are left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const PANEL_STYLES: &str = "graphic styles with gradient or pattern fills, with fills, strokes or effects the editing data can't hold as a graphic style, with a name another style has, with names over 1,024 bytes, or past 4,096 styles, are left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const PANEL_EMPTY: &str =
|
||||
"a Graphic Styles panel with no style written opens from the editing data with a new document's styles";
|
||||
pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style";
|
||||
|
||||
/// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity
|
||||
/// masks or symbols linked to a graphic style?
|
||||
pub(in crate::ai_editing) fn has_graphic_styles(doc: &Document) -> bool {
|
||||
fn linked(n: &Node) -> bool {
|
||||
n.extras().graphic_style.is_some()
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| linked(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|m| linked(m)))
|
||||
/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style.
|
||||
pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
|
||||
fn count(n: &Node) -> usize {
|
||||
let own = usize::from(n.extras().graphic_style.is_some());
|
||||
let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art));
|
||||
own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum())
|
||||
}
|
||||
doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles || doc.layers.iter().any(|l| linked(l)) || doc.symbols.iter().any(|s| linked(&s.art))
|
||||
doc.layers.iter().map(|l| count(l)).sum::<usize>() + doc.symbols.iter().map(|s| count(&s.art)).sum::<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, 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.
|
||||
/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic
|
||||
/// style, 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.
|
||||
pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap<NodeId, Arc<Node>> {
|
||||
fn walk(nodes: &[Arc<Node>], out: &mut HashMap<NodeId, Arc<Node>>) {
|
||||
for n in nodes {
|
||||
if candidate(n) {
|
||||
out.insert(n.id, n.clone());
|
||||
continue;
|
||||
}
|
||||
// Baking a container with effects of its own changes its members, which keep their ids:
|
||||
// they are written as baking left them.
|
||||
// A group's members are written with a style of their own only when the group is written
|
||||
// without one. Baking a container with effects of its own changes its members, which
|
||||
// keep their ids: they are written as baking left them.
|
||||
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &n.kind
|
||||
&& !bakes_members(n)
|
||||
{
|
||||
@@ -70,7 +73,8 @@ fn bakes_members(n: &Node) -> bool {
|
||||
|
||||
/// Whether `n` may be written with an art style ([`styled_objects`]).
|
||||
fn candidate(n: &Node) -> bool {
|
||||
if n.appearance.effects.is_empty() || n.knockout_shape {
|
||||
let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some();
|
||||
if !styled || n.knockout_shape {
|
||||
return false;
|
||||
}
|
||||
match &n.kind {
|
||||
@@ -97,25 +101,39 @@ fn plain_member(n: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`).
|
||||
/// The art styles written: the Graphic Styles panel's (listed, in its order) and those objects
|
||||
/// name, each with its text after its name (`/ActiveStyle :` to `/Def ;`).
|
||||
#[derive(Default)]
|
||||
pub(in crate::ai_editing) struct Styles {
|
||||
/// Every style, in the order written: its name and text.
|
||||
known: Vec<(String, String)>,
|
||||
/// The name of each style by its text.
|
||||
/// The panel's styles written, by the graphic style's id.
|
||||
panel: HashMap<u32, String>,
|
||||
/// The panel's styles written, in its order.
|
||||
listed: Vec<String>,
|
||||
/// The names styles have, and the name of each object style by its text.
|
||||
names: HashSet<String>,
|
||||
by_text: HashMap<String, String>,
|
||||
next_anon: u64,
|
||||
/// How many objects were written with the graphic style they are linked to.
|
||||
pub(super) links_written: usize,
|
||||
}
|
||||
|
||||
impl Styles {
|
||||
/// The name of an object style of `text`: a style with the same text already written, else a
|
||||
/// new one named `Anon` and a number.
|
||||
/// new one named `Anon` and a number no other style has.
|
||||
fn object_style(&mut self, text: String) -> String {
|
||||
if let Some(name) = self.by_text.get(&text) {
|
||||
return name.clone();
|
||||
}
|
||||
self.next_anon += 1;
|
||||
let name = format!("Anon {}", self.next_anon);
|
||||
let name = loop {
|
||||
self.next_anon += 1;
|
||||
let name = format!("Anon {}", self.next_anon);
|
||||
if !self.names.contains(&name) {
|
||||
break name;
|
||||
}
|
||||
};
|
||||
self.names.insert(name.clone());
|
||||
self.by_text.insert(text.clone(), name.clone());
|
||||
self.known.push((name.clone(), text));
|
||||
name
|
||||
@@ -287,16 +305,30 @@ impl Writer<'_> {
|
||||
(!line.is_empty()).then_some(line)
|
||||
}
|
||||
|
||||
/// An appearance item's basic filter. `None` for an item with transparency or effects of its
|
||||
/// own, which the reader doesn't read on an object.
|
||||
/// An appearance item's filter: its basic filter, or a chain of it, its effects and its
|
||||
/// transparency.
|
||||
fn item_filter(&mut self, s: &mut String, item: &AppearanceItem, on: Option<(&BezPath, FillRule)>) -> Option<()> {
|
||||
if item.opacity() < 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let plain = item.opacity() >= 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty();
|
||||
let mut basic_text = String::new();
|
||||
// In a chain, the chain holds the item's visibility.
|
||||
let visible = item.visible() || !plain;
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on),
|
||||
AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()),
|
||||
AppearanceItem::Fill(f) => self.fill_filter(&mut basic_text, f, visible, on)?,
|
||||
AppearanceItem::Stroke(st) => self.stroke_filter(&mut basic_text, st, visible)?,
|
||||
}
|
||||
if plain {
|
||||
s.push_str(&basic_text);
|
||||
return Some(());
|
||||
}
|
||||
let _ = write!(s, "/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n{} /Visible ,\n", u8::from(item.visible()));
|
||||
s.push_str(&basic_text);
|
||||
for e in item.effects() {
|
||||
s.push_str(&self.effect_filter(e)?);
|
||||
}
|
||||
let opacity = if item.opacity().is_finite() { item.opacity().clamp(0.0, 1.0) } else { 1.0 };
|
||||
blend_filter(s, Xy { blend: item.blend(), opacity, ..Xy::OPAQUE });
|
||||
s.push_str(";\n /Part ,\n");
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain
|
||||
@@ -338,7 +370,13 @@ impl Writer<'_> {
|
||||
let p = vectorcraft_effects::merged_params(&e.id, &e.params);
|
||||
let mut d = Entries::default();
|
||||
let id = e.id.as_str();
|
||||
let name = if id.starts_with("stylize.") { self.stylize_entries(id, &p, &mut d)? } else { Self::path_entries(id, &p, &mut d)? };
|
||||
let name = if id.starts_with("stylize.") {
|
||||
self.stylize_entries(id, &p, &mut d)?
|
||||
} else if id.starts_with("warp.") || id.starts_with("distort.") || id.starts_with("blur.") {
|
||||
Self::distort_entries(id, &p, &mut d)?
|
||||
} else {
|
||||
Self::path_entries(id, &p, &mut d)?
|
||||
};
|
||||
let mut s = String::new();
|
||||
basic(&mut s, name, true, e.visible);
|
||||
s.push_str("/Dictionary : /NotRecorded ,\n");
|
||||
@@ -461,9 +499,140 @@ impl Writer<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The style object `orig` (`n` before baking) is written with; `None` when the reader wouldn't
|
||||
/// read the object as it is with it. A gradient fill is placed on `n`'s outline, as the object
|
||||
/// draws it. A style that isn't written leaves no gradient definition or warning behind.
|
||||
/// A Warp, Distort & Transform or Gaussian Blur effect's entries ([`Self::effect_filter`]) and
|
||||
/// its filter's name.
|
||||
fn distort_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
id if id.starts_with("warp.") => {
|
||||
let style = crate::import::warp_number(id.strip_prefix("warp.")?)?;
|
||||
d.int("DeformStyle", style);
|
||||
d.real("DeformValue", num(p, "bend")? / 100.0);
|
||||
d.real("DeformHoriz", num(p, "horizontal")? / 100.0);
|
||||
d.real("DeformVert", num(p, "vertical")? / 100.0);
|
||||
d.bool("Rotate", word(p, "orientation").as_deref() == Some("vertical"));
|
||||
"Adobe Deform" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.transform" => {
|
||||
d.real("scaleH_Factor", num(p, "scaleH")? / 100.0);
|
||||
d.real("scaleV_Factor", num(p, "scaleV")? / 100.0);
|
||||
d.real("moveH_Pts", num(p, "moveH")?);
|
||||
// Art space has y up.
|
||||
d.real("moveV_Pts", 0.0 - num(p, "moveV")?);
|
||||
d.real("rotate_Radians", num(p, "rotate")?.to_radians());
|
||||
d.int("numCopies", num(p, "copies")? as i64);
|
||||
d.int("pinPoint", num(p, "reference")? as i64);
|
||||
d.bool("reflectX", flag(p, "reflectX")?);
|
||||
d.bool("reflectY", flag(p, "reflectY")?);
|
||||
d.bool("transformObjects", flag(p, "objects")?);
|
||||
d.bool("scaleLines", flag(p, "strokes")?);
|
||||
d.bool("transformPatterns", flag(p, "patterns")?);
|
||||
d.bool("randomize", false);
|
||||
"Adobe Transform" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.freeDistort" => {
|
||||
let corners = p.get("corners")?.as_array()?;
|
||||
let corner = |i: usize| -> Option<(f64, f64)> {
|
||||
let c = corners.get(i)?.as_array()?;
|
||||
Some((c.first()?.as_f64()?, c.get(1)?.as_f64()?))
|
||||
};
|
||||
// The source frame is the unit square (y up); destination corners 0, 1, 3, 2 are
|
||||
// the catalog's top left, top right, bottom right, bottom left (y down).
|
||||
for (i, (h, v)) in [(0.0, 1.0), (1.0, 1.0), (0.0, 0.0), (1.0, 0.0)].into_iter().enumerate() {
|
||||
d.real(&format!("src{i}h"), h);
|
||||
d.real(&format!("src{i}v"), v);
|
||||
}
|
||||
for (i, from) in [0, 1, 3, 2].into_iter().enumerate() {
|
||||
let (x, y) = corner(from)?;
|
||||
d.real(&format!("dst{i}h"), x);
|
||||
d.real(&format!("dst{i}v"), 1.0 - y);
|
||||
}
|
||||
"Adobe Free Distort" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.puckerBloat" => {
|
||||
d.real("d_factor", num(p, "amount")?);
|
||||
"Adobe Punk and Bloat" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.twist" => {
|
||||
d.real("angle", num(p, "angle")?);
|
||||
"Adobe Twirl" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"distort.zigZag" => {
|
||||
let relative = flag(p, "relative")?;
|
||||
d.real("absoluteness", if relative { 0.0 } else { 1.0 });
|
||||
d.real(if relative { "relAmount" } else { "amount" }, num(p, "size")?);
|
||||
d.real("ridges", num(p, "ridges")?);
|
||||
d.real("roundness", if word(p, "points").as_deref() == Some("smooth") { 1.0 } else { 0.0 });
|
||||
"Adobe Zigzag" // brand-ok: the filter's name in the files
|
||||
}
|
||||
"blur.gaussian" => {
|
||||
d.real("blur", num(p, "radius")?);
|
||||
"Adobe PSL Gaussian Blur" // brand-ok: the filter's name in the files
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The Graphic Styles panel's styles that the reader reads as they are: each checked, and
|
||||
/// the others left out with a warning. Their names are taken before the art's styles are.
|
||||
pub(in crate::ai_editing) fn panel_styles(&mut self) {
|
||||
let doc = self.doc;
|
||||
// A new document's styles are what the reader gives a file without a style list; they are
|
||||
// still written when an object is linked to one of them, so that it keeps its link.
|
||||
let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some());
|
||||
if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles {
|
||||
return;
|
||||
}
|
||||
for g in doc.graphic_styles.iter() {
|
||||
self.styles.names.insert(g.name.clone());
|
||||
}
|
||||
let mut readable: Vec<(&GraphicStyle, String)> = vec![];
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
for g in doc.graphic_styles.iter() {
|
||||
if readable.len() >= crate::import::MAX_LISTED || g.name.len() > crate::import::MAX_LISTED_NAME || seen.contains(g.name.as_str()) {
|
||||
self.warn(PANEL_STYLES);
|
||||
continue;
|
||||
}
|
||||
let xy = Xy { blend: g.blend, opacity: g.opacity.clamp(0.0, 1.0), isolate: g.isolate, knockout: g.knockout, shape: false };
|
||||
let text = self.style_text(&g.appearance, xy, None).filter(|t| panel_reads(g, t));
|
||||
let Some(text) = text else {
|
||||
self.warn(PANEL_STYLES);
|
||||
continue;
|
||||
};
|
||||
seen.insert(g.name.as_str());
|
||||
readable.push((g, text));
|
||||
}
|
||||
// The list as the reader reads it: names too long, and styles past its count, aren't.
|
||||
let list: String = readable.iter().map(|(g, _)| ps::bytes(g.name.as_bytes()) + "\n").collect();
|
||||
let listed: HashSet<String> = crate::import::read_style_list(list.as_bytes()).into_iter().collect();
|
||||
for (g, text) in readable {
|
||||
if !listed.contains(&g.name) {
|
||||
self.warn(PANEL_STYLES);
|
||||
continue;
|
||||
}
|
||||
if g.id != 0 {
|
||||
self.styles.panel.insert(g.id, g.name.clone());
|
||||
}
|
||||
self.styles.listed.push(g.name.clone());
|
||||
self.styles.known.push((g.name.clone(), text));
|
||||
}
|
||||
// The reader gives a file without a style list a new document's styles.
|
||||
if self.styles.listed.is_empty() {
|
||||
self.warn(PANEL_EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
/// Warn when fewer objects were written with their graphic style than `links` are linked to one.
|
||||
pub(in crate::ai_editing) fn lost_links(&mut self, links: usize) {
|
||||
if self.styles.links_written < links {
|
||||
self.warn(STYLE_LINKS);
|
||||
}
|
||||
}
|
||||
|
||||
/// The style object `orig` (`n` before baking) is written with: the graphic style it is linked
|
||||
/// to when it has that style's appearance and transparency and the panel's style is written,
|
||||
/// else a style of its own; `None` when the reader wouldn't read the object as it is with
|
||||
/// either. A gradient fill is placed on `n`'s outline, as the object draws it. A style of its own
|
||||
/// that isn't written leaves no gradient definition or warning behind.
|
||||
pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option<String> {
|
||||
if let NodeKind::Group { children, .. } = &orig.kind
|
||||
&& bakes_members(orig)
|
||||
@@ -471,6 +640,21 @@ impl Writer<'_> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let doc = self.doc;
|
||||
if let Some(id) = orig.extras().graphic_style
|
||||
&& let Some(name) = self.styles.panel.get(&id).cloned()
|
||||
&& let Some(g) = doc.graphic_style_by_id(id)
|
||||
&& g.transparency_matches(orig)
|
||||
&& g.appearance_on(orig).approx_eq(&orig.appearance)
|
||||
&& let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone())
|
||||
&& object_reads(orig, &text)
|
||||
{
|
||||
self.styles.links_written += 1;
|
||||
return Some(name);
|
||||
}
|
||||
if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) {
|
||||
return None;
|
||||
}
|
||||
let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_)));
|
||||
let saved = (graded.then(|| self.gradient_mark()), self.warnings.len());
|
||||
let path = drawn_outline(n, orig);
|
||||
@@ -583,6 +767,20 @@ impl Writer<'_> {
|
||||
}
|
||||
s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n");
|
||||
}
|
||||
|
||||
/// The panel's styles written, in its order, in a non-printing block:
|
||||
/// `%AI9_BeginArtStyleList`, a name on each line, `%AI9_EndArtStyleList`.
|
||||
pub(in crate::ai_editing) fn art_style_list(&self, s: &mut String) {
|
||||
if self.styles.listed.is_empty() {
|
||||
return;
|
||||
}
|
||||
s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n");
|
||||
for name in &self.styles.listed {
|
||||
s.push_str(&ps::bytes(name.as_bytes()));
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str("%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline a path's or compound path's style places a gradient fill on.
|
||||
@@ -609,15 +807,38 @@ 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.
|
||||
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));
|
||||
ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only)
|
||||
}
|
||||
|
||||
/// Does the reader read graphic style `g` from style text `text` as it is?
|
||||
fn panel_reads(g: &GraphicStyle, text: &str) -> bool {
|
||||
let section = known_style("x", text);
|
||||
let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false };
|
||||
// The opacity within the written number's precision.
|
||||
read.transparency.is_some_and(|t| (t.0 - g.opacity).abs() <= 1e-4 && (t.1, t.2, t.3) == (g.blend, g.isolate, g.knockout))
|
||||
&& same_appearance(&read.appearance, &g.appearance)
|
||||
}
|
||||
|
||||
/// 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?
|
||||
/// 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`])?
|
||||
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 || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) {
|
||||
if !read.simple
|
||||
|| (group && !read.appearance.items.is_empty())
|
||||
|| (!group && read.pathfinder)
|
||||
|| !crate::import::style_moves_with(section.as_bytes(), "x", n)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !group {
|
||||
|
||||
@@ -70,7 +70,9 @@ use graphics::{GState, Out};
|
||||
use interp::{Fault, Interp};
|
||||
use obj::PsError;
|
||||
|
||||
pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member};
|
||||
pub(crate) use ai::{
|
||||
MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, symbol_art_member, warp_number,
|
||||
};
|
||||
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
|
||||
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from};
|
||||
pub(crate) use native::{MAX_TEXT, scale_document};
|
||||
|
||||
@@ -41,9 +41,10 @@ pub use read::{
|
||||
BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside,
|
||||
variables,
|
||||
};
|
||||
pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member};
|
||||
pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, read_style_list, slot_frame, slot_of, style_moves_with, symbol_art_member};
|
||||
|
||||
pub(crate) use read::is_look_slot;
|
||||
pub(crate) use style::{MAX_LISTED, MAX_LISTED_NAME, warp_number};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -136,7 +136,7 @@ pub(crate) use art::join_and;
|
||||
use art::preview_png;
|
||||
pub use document::{BindingRef, Brushes, PaletteSwatches, VariableRead, artboards, bindings_note, brushes, graphic_styles, palette, variables};
|
||||
pub(crate) use document::{slot_frame, slot_of};
|
||||
pub(crate) use ops::read_art_style;
|
||||
pub(crate) use ops::{read_art_style, read_style_list, style_moves_with};
|
||||
pub use types::IMAGE_TOO_LARGE;
|
||||
use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1131,6 +1131,13 @@ pub(crate) struct StyleRead {
|
||||
pub simple: 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.
|
||||
pub transparency: Option<(f32, BlendMode, bool, Knockout)>,
|
||||
}
|
||||
|
||||
/// The names a style list section gives graphic styles, as the reader reads them.
|
||||
pub(crate) fn read_style_list(section: &[u8]) -> Vec<String> {
|
||||
crate::import::ai::style::art_style_list(section).iter().map(|raw| obj::text(raw)).filter(|name| !name.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`],
|
||||
@@ -1149,7 +1156,14 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
|
||||
}
|
||||
}
|
||||
let appearance = appearance_from_art_style(&st)?;
|
||||
Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder })
|
||||
Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency })
|
||||
}
|
||||
|
||||
/// Is art style `name` of `section` read on `object` as far as an effect that moves the outline
|
||||
/// goes: the style has none, or `object` is one [`moves_with`] reads it on, as [`styled`] checks?
|
||||
/// `false` when the section doesn't define it.
|
||||
pub(crate) fn style_moves_with(section: &[u8], name: &str, object: &Node) -> bool {
|
||||
crate::import::ai::style::art_styles(section).remove(name).is_some_and(|st| !st.moves_outline() || moves_with(object, &st))
|
||||
}
|
||||
|
||||
/// A style's stroke as an appearance item (`None` for a fill).
|
||||
|
||||
@@ -113,6 +113,12 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> {
|
||||
id.strip_prefix("warp.")
|
||||
}
|
||||
|
||||
/// The number `DeformStyle` gives warp style `style` (`arc`, `arcLower`, …), from 1.
|
||||
pub(crate) fn warp_number(style: &str) -> Option<i64> {
|
||||
let at = WARPS.iter().position(|id| id.strip_prefix("warp.") == Some(style))?;
|
||||
i64::try_from(at + 1).ok()
|
||||
}
|
||||
|
||||
/// The Effect › Pathfinder effects by the `Command` number of their filter. `Command` 0 is Add
|
||||
/// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137);
|
||||
/// the numbers 1 to 11 are VectorCraft's own reader rules, learned from saved files. Trap (12) isn't
|
||||
@@ -312,9 +318,9 @@ impl Default for Filter {
|
||||
const MAX_FILTERS: usize = 4096;
|
||||
|
||||
/// Most entries read from a style list.
|
||||
const MAX_LISTED: usize = 4096;
|
||||
pub(crate) const MAX_LISTED: usize = 4096;
|
||||
/// The longest style name read from a style list, in bytes.
|
||||
const MAX_LISTED_NAME: usize = 1024;
|
||||
pub(crate) const MAX_LISTED_NAME: usize = 1024;
|
||||
|
||||
/// The style list (`%AI9_BeginArtStyleList`, between its markers): one literal string per style,
|
||||
/// the Graphic Styles panel's order. Each name is its string's bytes, escapes undone; other tokens,
|
||||
|
||||
@@ -1187,6 +1187,15 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() {
|
||||
assert!(!art_styles(data.as_bytes())["Odd"].plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_numbers_are_the_dialogs_order() {
|
||||
for n in 1..=15 {
|
||||
let style = warp_style(n).unwrap();
|
||||
assert_eq!(warp_number(style), Some(n), "{style}");
|
||||
}
|
||||
assert_eq!((warp_style(0), warp_style(16), warp_number("arcs"), warp_number("")), (None, None, None, None));
|
||||
}
|
||||
|
||||
/// A style as a writer checks it: the reader's appearance, with a gradient fill as the object's own,
|
||||
/// and nothing for styles it doesn't read.
|
||||
#[test]
|
||||
@@ -1200,6 +1209,7 @@ fn a_style_is_read_as_a_writer_checks_it() {
|
||||
let got = read(&data, Some(&own)).unwrap();
|
||||
assert_eq!(got.appearance.items.len(), 1);
|
||||
assert_eq!(got.appearance.items[0].paint(), &own);
|
||||
assert_eq!(got.transparency.map(|t| (t.0, t.1)), Some((0.5, BlendMode::Multiply)));
|
||||
assert!(got.simple && !got.pathfinder);
|
||||
// A gradient that isn't the object's own, a pattern, another name, two gradients: nothing.
|
||||
assert!(read(&data, None).is_none());
|
||||
|
||||
@@ -93,8 +93,13 @@ private editing data. VectorCraft:
|
||||
group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern
|
||||
fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with
|
||||
live effects are written as their vector art with their geometry effects applied, and their other effects, such as
|
||||
Drop Shadow, the glows and Feather, are left out with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large
|
||||
canvas.
|
||||
Drop Shadow, the glows and Feather, are left out with a warning. A path, compound path or group linked to a graphic style names it when it has the style's
|
||||
appearance and the style is written; other links to graphic styles, those on a styled group's members included, are
|
||||
left out with a warning. The Graphic Styles panel's styles are written in its order, with their fills, strokes, transparency and effects (Warp,
|
||||
Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag and Gaussian Blur too). Styles with gradient or pattern fills,
|
||||
with fills, strokes or effects the reader doesn't read back, with a name another style has, with names over 1,024
|
||||
bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's
|
||||
styles, with a warning. No art style is written on a large canvas.
|
||||
Point type, area type (with its rows, columns,
|
||||
gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for
|
||||
each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift,
|
||||
|
||||
Reference in New Issue
Block a user