Merge remote-tracking branch 'upstream/main' into fix/ai-import-brushes

# Conflicts:
#	docs/file-format-parity.md
#	docs/target-app-parity.md
This commit is contained in:
Don Omondi
2026-11-08 19:21:57 -05:00
198 changed files with 13238 additions and 700 deletions
+19 -14
View File
@@ -122,16 +122,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
@@ -166,8 +167,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`]).
@@ -249,7 +251,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);
@@ -283,15 +286,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.
@@ -315,6 +319,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, &library);
if !w.texts.is_empty() {
+500 -39
View File
@@ -4715,6 +4715,48 @@ fn symbols_painting_patterns_through_brush_art_are_placed_as_art() {
assert_eq!(instances(&back.layers[0]), 0, "the instance is its art");
}
/// A symbol first defined from the drawn look of a group with a style, through a member's scatter
/// brush, still warns that its art's own effects are left out, and so does a later instance of it.
#[test]
fn a_symbol_defined_inside_a_drawn_look_warns_of_its_own_effects() {
let mut doc = Document::new(200.0, 100.0);
let mut shadowed = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK);
shadowed.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into();
doc.symbols.push(Symbol { dynamic: false, name: "Shadowed".into(), art: Arc::new(shadowed) });
let dot = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK);
let placed = instance(&mut doc, "Shadowed", Affine::translate((8.0, 0.0)));
let art = group(&mut doc, vec![dot, placed], false);
let brush = vectorcraft_brush::Brush {
name: "Scattered".into(),
kind: vectorcraft_brush::BrushKind::Scatter(vectorcraft_brush::Scatter {
art,
size: (100.0, 100.0),
spacing: (100.0, 100.0),
scatter: (0.0, 0.0),
rotation: (0.0, 0.0),
colorization: vectorcraft_brush::Colorization::None,
..Default::default()
}),
};
vectorcraft_brush::store(&mut doc, &[brush]);
let mut line =
Node::path(doc.alloc_id(), shapes::line((10.0, 50.0).into(), (90.0, 50.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 1.0));
line.appearance.stroke_mut().unwrap().brush = Some("Scattered".into());
let edge = square(&mut doc, Rect::new(10.0, 60.0, 30.0, 80.0), LEAF);
let mut styled = group(&mut doc, vec![line, edge], false);
styled.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into();
put(&mut doc, named(styled, "Styled"));
let out = export_ai_editing(&doc, &opts()).unwrap();
let p = shown(&out.program);
assert!(p.contains("%AI14_BeginSymbol") && p.contains("1 (Anon 1) XW"), "{p}");
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
let later = instance(&mut doc, "Shadowed", Affine::translate((150.0, 20.0)));
put(&mut doc, later);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
}
/// A symbol whose stroke is written as its outline (an outside stroke): a scaled instance, a scaled
/// instance of a symbol holding it and a turned and mirrored instance are written as their art,
/// which keeps the stroke the canvas draws; a moved instance stays an instance.
@@ -5388,6 +5430,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
}
@@ -5511,12 +5580,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![];
@@ -5528,13 +5609,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(
@@ -5548,6 +5655,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}");
@@ -5567,8 +5676,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);
@@ -5596,10 +5706,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]
@@ -5630,6 +5771,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]
@@ -5781,7 +5961,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);
@@ -5810,9 +5991,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);
@@ -5830,8 +6030,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 { .. })));
@@ -5857,36 +6065,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
@@ -6011,6 +6319,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() {
@@ -6152,6 +6547,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]
@@ -6450,14 +6899,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);
+2 -2
View File
@@ -22,9 +22,9 @@ mod symbols;
mod texts;
mod variables;
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)]
+264 -43
View File
@@ -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,33 +20,35 @@ use crate::ai_ops;
use crate::ps;
pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of 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";
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)
{
@@ -69,7 +72,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 {
@@ -96,25 +100,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
@@ -286,16 +304,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
@@ -337,7 +369,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");
@@ -460,9 +498,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)
@@ -470,6 +639,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);
@@ -582,6 +766,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.
@@ -608,15 +806,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 {
@@ -203,6 +203,8 @@ impl Writer<'_> {
if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() {
self.warn(SYMBOL_OPTIONS);
}
// The art's effects are its own, not those of a drawn look that first places it.
let in_look = std::mem::take(&mut self.in_look);
self.defining_symbols.push(name.to_string());
let mut used = vec![];
vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| {
@@ -216,6 +218,7 @@ impl Writer<'_> {
if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS {
self.defining_symbols.pop();
self.warn(SYMBOL_COUNT);
self.in_look = in_look;
return false;
}
// The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the
@@ -291,6 +294,7 @@ impl Writer<'_> {
(self.wrote_fixed, self.inner_scale),
) = saved;
self.doc = page_doc;
self.in_look = in_look;
let art = std::mem::replace(&mut self.body, art);
self.defining_symbols.pop();
let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes()));
+5 -2
View File
@@ -73,7 +73,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, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
pub(crate) use native::{MAX_TEXT, scale_document};
@@ -306,7 +308,8 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<
/// The document of what `out` drew: one layer, clipped art in clipping groups (grouped art in
/// groups), the spot inks as spot swatches, in CMYK when most process colours were.
fn finish(mut out: Out) -> Imported {
let drawn = std::mem::take(&mut out.drawn);
let mut drawn = std::mem::take(&mut out.drawn);
graphics::merge_strips(&mut drawn, &mut out.shadings);
let mut children = vectorcraft_doc::clipnest::nest(&mut out.doc, drawn);
if !out.shadings.is_empty() {
graphics::collapse(&mut children, &out.shadings);
+6 -3
View File
@@ -43,12 +43,15 @@ pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS;
#[cfg(test)]
pub(crate) use read::with_image_limits;
pub use read::{
BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read,
read_beside, variables,
BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, Symbols, active_artboard, artboards, bindings_note, brushes, graphic_styles,
palette, read, read_beside, symbols, variables,
};
pub(crate) use read::{
MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, symbol_art_member,
};
pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, read_over_page, slot_frame, slot_of, 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;
+3 -2
View File
@@ -142,10 +142,11 @@ mod types;
pub(crate) use art::join_and;
use art::preview_png;
pub use document::{
BindingRef, Brushes, PaletteSwatches, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, variables,
BindingRef, Brushes, PaletteSwatches, Symbols, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette,
symbols, 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};
#[cfg(test)]
pub(crate) use raster::with_image_limits;
pub use types::PlacedPage;
+19 -3
View File
@@ -503,7 +503,15 @@ impl<'a> Reader<'a> {
let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else {
return;
};
let art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
let art: Vec<Arc<Node>> = frame
.children
.iter()
.map(|n| {
let mut kept = self.doc.reid(n);
self.reid_masks(&mut kept, 0);
Arc::new(kept)
})
.collect();
// The objects of the art linked to graphic styles, by their ids in the document.
if !sub.style_links.is_empty() {
let mut ids = HashMap::new();
@@ -671,7 +679,7 @@ impl<'a> Reader<'a> {
named
}
/// Give the art of the opacity masks in `n` (a copy of a symbol's art or of a clipping path with ids of this
/// Give the art of the opacity masks in `n` (a copy of a symbol's, a pattern's or a brush's art, or of a clipping path, with ids of this
/// document, [`vectorcraft_doc::Document::reid`], which leaves masks as they are) ids of this
/// document too.
pub(super) fn reid_masks(&mut self, n: &mut Node, depth: usize) {
@@ -783,7 +791,15 @@ impl<'a> Reader<'a> {
&& sub.doc.images.is_empty();
let Some(frame) = sub.frames.pop().filter(|f| whole && !f.children.is_empty()) else { return };
self.brush_art_looks.insert(name.clone(), sub.drawn_looks);
let mut art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
let mut art: Vec<Arc<Node>> = frame
.children
.iter()
.map(|n| {
let mut kept = self.doc.reid(n);
self.reid_masks(&mut kept, 0);
Arc::new(kept)
})
.collect();
let node = match art.len() {
1 => art.pop().map(Arc::unwrap_or_clone),
_ => Some(Node::group(self.doc.alloc_id(), art)),
+67
View File
@@ -405,6 +405,73 @@ pub fn graphic_styles(data: &[u8], large_canvas: bool) -> (Vec<GraphicStyle>, f6
(r.graphic_styles().0, r.canvas_scale)
}
/// The symbols of a `.ai` file's editing data, as [`symbols`] reads them.
pub struct Symbols {
/// The symbols in the Symbols panel's order, each with its Symbol Options › Registration.
pub symbols: Vec<(Symbol, vectorcraft_doc::SymbolRegistration)>,
/// The image blobs the symbols' art shows, by key.
pub images: std::collections::BTreeMap<String, ImageBlob>,
/// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds
/// them to a document read from the layers.
pub swatches: Vec<Swatch>,
}
/// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library
/// has none), as [`read`] makes them: in the symbol list's order, without those that hold an
/// instance of a symbol the data doesn't define, and with each object of their art that is linked
/// to a listed graphic style linked to it by the id [`graphic_styles`] gives that style. The
/// symbols are those defined before reading stops and before the first layer; `None` when there
/// are none.
pub fn symbols(data: &[u8]) -> Option<Symbols> {
crate::import::ai::find(data, b"BeginSymbol")?;
let mut r = Reader::with_names(data);
// The symbols come before the art, so an error in the art leaves them read.
let _ = r.run();
r.check_symbols();
r.order_symbols();
if r.doc.symbols.is_empty() {
return None;
}
let (styles, ids) = r.graphic_styles();
let links: HashMap<NodeId, u32> = r.style_links.iter().filter_map(|(node, name)| Some((*node, *ids.get(name)?))).collect();
if !links.is_empty() {
for symbol in &mut r.doc.symbols {
link_styles(std::slice::from_mut(&mut symbol.art), &links, &styles, 0);
}
}
let mut shown = Document::new(1.0, 1.0);
shown.symbols = std::mem::take(&mut r.doc.symbols);
let mut images = std::collections::BTreeMap::new();
shown.visit_images(|_, im| {
if let Some(blob) = r.doc.images.get(&im.key) {
images.insert(im.key.clone(), blob.clone());
}
});
// The colors the art is linked to that the file defines, once each.
let mut linked = std::collections::HashSet::new();
for s in &mut shown.symbols {
vectorcraft_doc::swatches::map_node_solid_paints(Arc::make_mut(&mut s.art), &mut |_, link, _| {
linked.extend(link.clone());
false
});
}
let mut swatches: Vec<Swatch> = vec![];
let colors = r.spots.iter().chain(&r.stop_spots).map(|n| (n, n.spot)).chain(r.custom_colors.iter().map(|n| (n, true)));
for (n, spot) in colors {
if linked.remove(&n.name) {
swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot });
}
}
let symbols = std::mem::take(&mut shown.symbols)
.into_iter()
.map(|s| {
let reg = r.doc.symbol_registration(&s.name);
(s, reg)
})
.collect();
Some(Symbols { symbols, images, swatches })
}
/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and
/// those of a `/Document` still open when it stopped (its art can be what stopped it).
fn artboards_read(data: &[u8]) -> (Vec<(String, Rect)>, Option<usize>) {
+15 -1
View File
@@ -1182,6 +1182,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`],
@@ -1200,7 +1207,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).
+8 -2
View File
@@ -122,6 +122,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
@@ -326,9 +332,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,
+10
View File
@@ -1366,6 +1366,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]
@@ -1379,6 +1388,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());
+104
View File
@@ -5974,6 +5974,37 @@ fn an_object_in_a_brushs_art_with_a_style_this_reads_comes_in_as_itself() {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// The art of an opacity mask in a brush's art takes ids of the document, as the rest of the art
/// does: no object of the brush's art shares an id with an object of the layers.
#[test]
fn a_brushs_opacity_mask_art_takes_ids_of_the_document() {
fn ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) {
out.push(n.id);
if let Some(m) = n.extras().mask.as_ref() {
ids(&m.art, out);
}
for c in n.children().into_iter().flatten() {
ids(c, out);
}
}
let square = rect_path(0.0, 0.0, 10.0, 10.0);
let mask_art: String = format!("1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
let section = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{RED}{square}f\n{mask}%AI8_EndBrushPattern\n");
let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect();
let r = read_ok(&format!("{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(&shapes)));
let lib = vectorcraft_brush::library(&r.doc);
let spray = lib.iter().find(|b| b.name == "Spray").unwrap_or_else(|| panic!("{:?}", r.warnings));
let art = spray.art();
assert!(art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {art:#?}");
let mut all = Vec::new();
for n in r.doc.layers.iter().map(|l| &**l).chain(art) {
ids(n, &mut all);
}
let unique: std::collections::HashSet<_> = all.iter().copied().collect();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
/// An art styles section inside a symbol's, a pattern's or a brush's art is skipped: the file's own
/// art styles still style the layers' objects read after the definition.
#[test]
@@ -6184,6 +6215,50 @@ fn an_object_in_a_symbols_art_with_a_listed_style_is_linked() {
assert_eq!(art.extras().graphic_style, Some(1), "{art:#?}");
}
/// Editing data with symbols and no layers (a symbol library) gives its symbols: in the symbol
/// list's order with the unlisted one after them, with their registration points and the images
/// their art shows, without the one holding an instance of a symbol the data doesn't define, and
/// with the object linked to a listed graphic style linked to it by its id.
#[test]
fn a_symbol_library_without_layers_gives_its_symbols() {
let styles = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]);
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
let nested = instance("Dot", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]);
let lost = instance("Ghost", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]);
let m = "[ 5 0 0 5 -8096 8244.5 ]";
let raster = format!(
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\
%%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n"
);
let defs = symbols(
&(symbol("Dot", &dot(), 1)
+ &symbol("Pic", &raster, 5)
+ &symbol("Pair", &nested, 5)
+ &symbol("Tile", &styled_square("Blue"), 5)
+ &symbol("Lost", &lost, 5)),
);
let list = "%AI24_BeginSymbolList\n(Tile)\n(Pair)\n(Lost)\n(Dot)\n%AI24_EndSymbolList\n";
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{styles}{defs}{list}");
let read = super::symbols(stream(&body).as_bytes()).expect("the library's symbols");
let names: Vec<&str> = read.symbols.iter().map(|(s, _)| s.name.as_str()).collect();
assert_eq!(names, ["Tile", "Pair", "Dot", "Pic"]);
let registrations: Vec<u8> = read.symbols.iter().map(|(_, r)| r.0).collect();
let center = vectorcraft_doc::SymbolRegistration::CENTER.0;
assert_eq!(registrations, [center, center, 0, center]);
let Some(((tile, _), (pair, _))) = read.symbols.first().zip(read.symbols.get(1)) else { panic!("two symbols") };
assert_eq!(tile.art.extras().graphic_style, Some(1), "{:#?}", tile.art);
// Dot sits at (10, 5) in Pair's art.
let mut doc = Document::new(1.0, 1.0);
doc.symbols = read.symbols.iter().map(|(s, _)| s.clone()).collect();
assert_eq!(pair.art.visual_bounds_with(&doc.symbol_boxes()), Some(Rect::new(8.0, 3.0, 12.0, 7.0)));
let keys: Vec<String> =
doc.symbols.iter().filter_map(|s| if let NodeKind::Image(im) = &s.art.kind { Some(im.key.clone()) } else { None }).collect();
assert_eq!(keys.len(), 1);
assert_eq!(read.images.keys().cloned().collect::<Vec<_>>(), keys);
// Without symbols there are none to give.
assert!(super::symbols(stream(&styles).as_bytes()).is_none());
}
#[test]
fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() {
let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]);
@@ -6196,6 +6271,35 @@ fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() {
assert_eq!(square.extras().graphic_style, Some(1), "{square:#?}");
}
/// The art of an opacity mask in a pattern's art takes ids of the document, as the rest of the art
/// does: no object of the pattern's art shares an id with an object of the layers.
#[test]
fn a_patterns_opacity_mask_art_takes_ids_of_the_document() {
fn ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) {
out.push(n.id);
if let Some(m) = n.extras().mask.as_ref() {
ids(&m.art, out);
}
for c in n.children().into_iter().flatten() {
ids(c, out);
}
}
let square = rect_path(0.0, 0.0, 10.0, 10.0);
let mask_art: String = format!("1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
let pattern = format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{RED}{square}f\n{mask}E\n%AI3_EndPattern\n");
let shapes: String = (0..12).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 20.0, 0.0, f64::from(i) * 20.0 + 10.0, 10.0))).collect();
let r = read_ok(&format!("{pattern}{}", layer(&shapes)));
let def = r.doc.pattern("P").unwrap_or_else(|| panic!("{:?}", r.warnings));
assert!(def.art.iter().any(|n| n.extras().mask.is_some()), "the mask is read: {:#?}", def.art);
let mut all = Vec::new();
for n in r.doc.layers.iter().chain(&def.art) {
ids(n, &mut all);
}
let unique: std::collections::HashSet<_> = all.iter().copied().collect();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
#[test]
fn an_object_in_a_version_7_patterns_tile_with_a_listed_style_is_linked() {
let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]);
+2
View File
@@ -21,9 +21,11 @@ mod ops;
mod paint;
mod path;
mod state;
mod strips;
pub(crate) use paint::collapse;
pub(crate) use state::{GState, Out, Space, process};
pub(crate) use strips::merge_strips;
/// Most objects one file makes.
const MAX_NODES: usize = 1 << 20;
+323
View File
@@ -0,0 +1,323 @@
//! Gradients a program paints as a row of abutting strips, each in one colour (the way many
//! PostScript writers draw a smooth shading at Level 2, which has no `shfill`), read as one
//! linear gradient.
//!
//! Drawn one by one, abutting strips leave seams: a renderer that anti-aliases each fill lets the
//! art below show through along every shared edge (PDF 32000-1:2008 §10.6.4 notes the effect,
//! "conflation", for adjacent areas painted separately), which reads as thin stripes across the
//! gradient, and as a tint of the colour below over the whole of it where the strips are only a
//! pixel or two wide. A run of them under the same clips becomes one object filled with a linear
//! gradient through the strips' colours, which has no seams and stays editable.
//!
//! Only strips that clearly are a gradient merge: at least [`MIN_STRIPS`] parallelograms of one
//! length, side by side in one direction with no gap or overlap, each a small step in colour from
//! the one before ([`MAX_STEP`]). Abutting blocks of distinct colours (a bar chart, a row of
//! swatches) stay as they are.
use super::{
Affine, Appearance, AppearanceItem, BezPath, Color, Drawn, FillLayer, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, HashSet,
MAX_STITCH_STOPS, Node, NodeId, NodeKind, Paint, PathData, PathEl, Point, Vec2,
};
/// Fewest strips (that paint something) a gradient is read from.
const MIN_STRIPS: usize = 6;
/// Largest change of any colour component (0 to 1) from one strip to the next.
const MAX_STEP: f32 = 0.08;
/// How far apart corners may be and still meet (points).
const EPS: f64 = 0.01;
/// Largest change of any colour component a stop left out may make to the gradient.
const TOLERANCE: f32 = 0.5 / 255.0;
/// A filled parallelogram in one process colour.
struct Strip {
corners: [Point; 4],
model: u8,
comps: [f32; 4],
color: Color,
overprint: bool,
}
/// The parallelogram `n` fills, in one process colour: `None` for anything else.
fn strip_of(n: &Node) -> Option<Strip> {
let NodeKind::Path { path, live: None, clipping: false, guide: false, .. } = &n.kind else { return None };
if n.appearance.items.len() != 1 {
return None;
}
let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { return None };
if f.opacity != 1.0 || !f.visible || !f.effects.is_empty() || f.blend != vectorcraft_color::BlendMode::Normal {
return None;
}
let Paint::Solid { color, swatch: None, .. } = &f.paint else { return None };
let (model, comps) = match *color {
Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]),
Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]),
Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]),
Color::Lab { .. } => return None,
};
let [sp] = path.subpaths.as_slice() else { return None };
let mut pts: Vec<Point> = Vec::with_capacity(5);
for a in &sp.anchors {
if a.h_in != a.p || a.h_out != a.p {
return None;
}
pts.push(a.p);
}
if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) <= EPS) {
pts.pop();
}
let [a, b, c, d] = pts.as_slice() else { return None };
// Opposite corners share a midpoint.
if (a.to_vec2() + c.to_vec2() - b.to_vec2() - d.to_vec2()).hypot() > EPS {
return None;
}
Some(Strip { corners: [*a, *b, *c, *d], model, comps, color: *color, overprint: f.overprint })
}
/// The edge two strips share, as it runs along the first: `(k, from, to)`, from its corner `k`.
fn shared_edge(a: &Strip, b: &Strip) -> Option<(usize, Point, Point)> {
let near = |p: Point, q: Point| p.distance(q) <= EPS;
for k in 0..4 {
let (p, q) = (a.corners[k], a.corners[(k + 1) % 4]);
if p.distance(q) <= EPS {
continue;
}
for j in 0..4 {
let (r, s) = (b.corners[j], b.corners[(j + 1) % 4]);
if (near(p, r) && near(q, s)) || (near(p, s) && near(q, r)) {
return Some((k, p, q));
}
}
}
None
}
/// Coordinates along a run of strips: a point is `o + s·w + t·v`, `v` along the strips (their
/// shared edges run from `t` = 0 to 1), `w` across them.
struct Axes {
o: Point,
w: Vec2,
v: Vec2,
inv: Affine,
}
impl Axes {
fn of(a: &Strip, b: &Strip) -> Option<Self> {
let (k, from, to) = shared_edge(a, b)?;
let w = a.corners[(k + 2) % 4] - a.corners[(k + 1) % 4];
let v = to - from;
let m = Affine::new([w.x, w.y, v.x, v.y, from.x, from.y]);
if w.hypot() <= EPS || m.determinant().abs() < 1e-9 * w.hypot() * v.hypot() {
return None;
}
Some(Self { o: from, w, v, inv: m.inverse() })
}
fn at(&self, s: f64, t: f64) -> Point {
self.o + self.w * s + self.v * t
}
/// Does `p` lie within the run's length, and across it from `lo` to `hi`?
fn holds(&self, p: Point, lo: f64, hi: f64) -> bool {
let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot());
let l = self.inv * p;
l.x >= lo - ss && l.x <= hi + ss && l.y >= -ts && l.y <= 1.0 + ts
}
/// The strip's extent across the run (`s` from, to), when it spans the run's length exactly.
fn band(&self, st: &Strip) -> Option<(f64, f64)> {
let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot());
let local: Vec<Point> = st.corners.iter().map(|p| self.inv * *p).collect();
let lo = local.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
let hi = local.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max);
let fits = local
.iter()
.all(|p| ((p.y).abs() <= ts || (p.y - 1.0).abs() <= ts) && ((p.x - lo).abs() <= ss || (p.x - hi).abs() <= ss) && p.x.is_finite());
// Each end of the strip touches both edges of the run.
let ends = [0.0, 1.0].iter().all(|t| local.iter().filter(|p| (p.y - t).abs() <= ts).count() == 2);
(fits && ends).then_some((lo, hi))
}
}
/// A run of strips found at one place in what was drawn: where it ends (exclusive), the object
/// it becomes, and whether that object covers the clip around it (so the clip path filled with
/// the gradient paints no more than the strips did).
struct Run {
start: usize,
end: usize,
node: Node,
covers_clip: bool,
}
/// Is `s` (from, to) too thin to paint anything?
fn thin(b: (f64, f64), axes: &Axes) -> bool {
(b.1 - b.0) * axes.w.hypot() <= EPS
}
/// The run of strips starting at `i`, if one does.
fn run_at(drawn: &Drawn, strips: &[Option<Strip>], i: usize) -> Option<Run> {
let chain = |k: usize| drawn.get(k).map(|(c, _)| c.iter().map(|c| c.id).collect::<Vec<_>>());
let here = chain(i)?;
let strip = |k: usize| strips.get(k).and_then(Option::as_ref).filter(|_| chain(k).as_ref() == Some(&here));
let first = strip(i)?;
// The run's axes come from its first two strips that paint something; a line before them
// (a strip of no width, as some writers draw first) joins the run where it lies on its edge.
let (lead, a, b) = match (strip(i + 1), strip(i + 2)) {
(Some(b), _) if Axes::of(first, b).is_some_and(|ax| ax.band(first).is_some_and(|f| !thin(f, &ax))) => (None, first, b),
(Some(a), Some(b)) => (Some(first), a, b),
_ => return None,
};
let axes = Axes::of(a, b)?;
let (ba, bb) = (axes.band(a)?, axes.band(b)?);
if thin(ba, &axes) || thin(bb, &axes) {
return None;
}
let ss = EPS / axes.w.hypot();
let forward = if (bb.0 - ba.1).abs() <= ss {
true
} else if (bb.1 - ba.0).abs() <= ss {
false
} else {
return None;
};
let mut edge = if forward { ba.0 } else { ba.1 };
if let Some(l) = lead {
let lb = axes.band(l)?;
if !thin(lb, &axes) || (lb.0 - edge).abs() > ss {
return None;
}
}
let at = i + usize::from(lead.is_some());
let mut bands: Vec<(f64, f64, &Strip)> = vec![];
let mut end = at;
while bands.len() < MAX_STITCH_STOPS {
let Some(st) = strip(end) else { break };
let Some(band) = axes.band(st) else { break };
let meets = if forward { (band.0 - edge).abs() <= ss } else { (band.1 - edge).abs() <= ss };
if !meets || st.overprint != a.overprint {
break;
}
if !thin(band, &axes) {
if let Some((_, _, prev)) = bands.last()
&& (prev.model != st.model || prev.comps.iter().zip(&st.comps).any(|(x, y)| (x - y).abs() > MAX_STEP))
{
break;
}
bands.push((band.0, band.1, st));
}
edge = if forward { band.1 } else { band.0 };
end += 1;
}
if bands.len() < MIN_STRIPS {
return None;
}
let lo = bands.iter().map(|b| b.0).fold(f64::INFINITY, f64::min);
let hi = bands.iter().map(|b| b.1).fold(f64::NEG_INFINITY, f64::max);
if !(hi - lo).is_finite() || hi - lo <= 0.0 {
return None;
}
bands.sort_by(|x, y| x.0.total_cmp(&y.0));
let stops: Vec<(f32, &Strip)> = bands.iter().map(|(s0, s1, st)| ((((s0 + s1) / 2.0 - lo) / (hi - lo)) as f32, *st)).collect();
let stops: Vec<GradientStop> = simplify(&stops).into_iter().map(|(t, st)| GradientStop::new(t, st.color)).collect();
// The gradient runs across the strips, square to them, from the run's edge at `lo`.
let len = axes.v.hypot();
let mut n = Vec2::new(-axes.v.y / len, axes.v.x / len);
let across = axes.w.dot(n) * (hi - lo);
if across < 0.0 {
n = -n;
}
let start = axes.at(lo, 0.0);
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
g.geom = Some(GradientGeom { start, end: start + n * across.abs(), aspect: 1.0, focal: None });
let mut bp = BezPath::new();
bp.move_to(axes.at(lo, 0.0));
bp.line_to(axes.at(hi, 0.0));
bp.line_to(axes.at(hi, 1.0));
bp.line_to(axes.at(lo, 1.0));
bp.close_path();
let item = AppearanceItem::Fill(FillLayer { overprint: a.overprint, ..FillLayer::new(Paint::Gradient(Box::new(g))) });
let (clips, first_node) = drawn.get(i)?;
// Every point of a clip's path, control points included, inside the run: its curves are too
// (each lies in the hull of its points), and the run's area is convex.
let covers_clip = clips.last().and_then(|c| c.region.as_ref()).is_some_and(|(clip, _)| {
clip.elements().iter().all(|el| match *el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => axes.holds(p, lo, hi),
PathEl::QuadTo(a, p) => axes.holds(a, lo, hi) && axes.holds(p, lo, hi),
PathEl::CurveTo(a, b, p) => axes.holds(a, lo, hi) && axes.holds(b, lo, hi) && axes.holds(p, lo, hi),
PathEl::ClosePath => true,
})
});
let node = Node::path(first_node.id, PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() });
Some(Run { start: i, end, node, covers_clip })
}
/// The stops `stops` (offset, strip) needs: those the gradient between the stops kept on either
/// side wouldn't come within [`TOLERANCE`] of are kept.
fn simplify<'a>(stops: &[(f32, &'a Strip)]) -> Vec<(f32, &'a Strip)> {
let mut kept: Vec<(f32, &Strip)> = vec![];
let Some(&first) = stops.first() else { return kept };
kept.push(first);
let mut from = 0;
while from + 1 < stops.len() {
// The furthest stop reachable from `from` with every stop between on the line.
let mut to = from + 1;
while to + 1 < stops.len() && on_line(stops, from, to + 1) {
to += 1;
}
if let Some(&s) = stops.get(to) {
kept.push(s);
}
from = to;
}
kept
}
/// Do the stops between `a` and `b` lie on the blend between those two?
fn on_line(stops: &[(f32, &Strip)], a: usize, b: usize) -> bool {
let (Some((ta, sa)), Some((tb, sb))) = (stops.get(a), stops.get(b)) else { return false };
let span = tb - ta;
stops.get(a + 1..b).unwrap_or_default().iter().all(|(t, s)| {
let f = if span > 0.0 { (t - ta) / span } else { 0.0 };
sa.comps.iter().zip(&sb.comps).zip(&s.comps).all(|((x, y), z)| (x + (y - x) * f - z).abs() <= TOLERANCE)
})
}
/// Runs of strips in `drawn` become one gradient-filled object each; one that covers its clip is
/// marked in `shadings`, so a clipping group of nothing else is its clip path filled with the
/// gradient (a clip larger than the strips keeps clipping the gradient to them).
pub(crate) fn merge_strips(drawn: &mut Drawn, shadings: &mut HashSet<NodeId>) {
let strips: Vec<Option<Strip>> = drawn.iter().map(|(_, n)| strip_of(n)).collect();
if strips.iter().filter(|s| s.is_some()).count() < MIN_STRIPS {
return;
}
let mut runs: Vec<Run> = vec![];
let mut i = 0;
while i < drawn.len() {
match run_at(drawn, &strips, i) {
Some(r) => {
i = r.end.max(i + 1);
runs.push(r);
}
None => i += 1,
}
}
if runs.is_empty() {
return;
}
let old = std::mem::take(drawn);
let mut runs = runs.into_iter().peekable();
let mut skip_to = 0;
for (k, item) in old.into_iter().enumerate() {
if k < skip_to {
continue;
}
if let Some(r) = runs.next_if(|r| r.start == k) {
skip_to = r.end;
if r.covers_clip {
shadings.insert(r.node.id);
}
drawn.push((item.0, r.node));
continue;
}
drawn.push(item);
}
}
+1 -1
View File
@@ -1192,7 +1192,7 @@ fn keep_differing(
}
/// Copy page drawing IDs, including mask art outside the ordinary child tree.
fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
pub(super) fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
let mut copy = doc.reid(source);
if let Some(mask) = &mut copy.extras_mut().mask {
mask.art = Arc::new(reid_page_art(doc, &mask.art));
+83 -18
View File
@@ -1,4 +1,4 @@
use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page};
use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page, reid_page_art};
use super::slots::LAYERS_UNREAD;
use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai};
@@ -7,11 +7,11 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
/// has one that can't be used, `visible` with a warning that says why, with the swatches of the
/// editing copy's palette ([`use_palette`]), its graphic styles ([`use_styles`]) and its variable
/// library ([`use_variables`]) when it could be read, and with its layer tree, empty,
/// below the page's layer when that could be read (#2033). A program in the legacy format without
/// layers (`illustrator`, see [`program_alone`]) is read with its art on one layer; when that
/// can't be used, the file is `visible` as it was.
/// editing copy's palette ([`use_palette`]), its graphic styles ([`use_styles`]), its symbols
/// ([`use_symbols`]) and its variable library ([`use_variables`]) when it could be read, and with
/// its layer tree, empty, below the page's layer when that could be read (#2033). A program in the
/// legacy format without layers (`illustrator`, see [`program_alone`]) is read with its art on one
/// layer; when that can't be used, the file is `visible` as it was.
pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool) -> Imported {
let Some(data) = editing_copy(ps) else {
let read = illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0).ok());
@@ -38,9 +38,10 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus
let mut v = visible;
if let Ok(data) = &data {
let have = v.document.patterns.len();
use_palette(data, &mut v.document);
let own_palette = use_palette(data, &mut v.document);
use_brushes(data, &mut v.document, have, &mut v.warnings);
use_styles(data, &mut v.document, true, 1.0, None);
use_symbols(data, &mut v.document, own_palette, &mut v.warnings);
use_variables(data, &mut v.document, &mut v.warnings);
}
if tree.is_empty() {
@@ -100,10 +101,11 @@ fn layer_tree(nodes: &[Arc<Node>]) -> Vec<Arc<Node>> {
/// notes, or a note saying why the layers weren't read when the editing data can't be decompressed
/// or its layers can't be read. The flag returned is `true` when the document is the one read from
/// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing
/// data was decompressed but its layers can't be read, and its swatches, brushes, graphic styles and
/// variables those of the editing data's palette ([`use_palette`]), brushes ([`use_brushes`]), styles
/// ([`use_styles`]) and variable library ([`use_variables`]) when it was decompressed: a graphic
/// style library, which has no layers, opens with its styles, and editing data with no layers opens
/// data was decompressed but its layers can't be read, and its swatches, brushes, graphic styles,
/// symbols and variables those of the editing data's palette ([`use_palette`]), brushes
/// ([`use_brushes`]), styles ([`use_styles`]), symbols ([`use_symbols`]) and variable library
/// ([`use_variables`]) when it was decompressed: a graphic style library or a symbol library, which
/// has no layers, opens with its styles or its symbols, and editing data with no layers opens
/// on the page of its active artboard under the checks of `place_pages`. `outlined`: the PDF part's type was
/// read as outlines (a file whose type shows then comes in as its PDF part).
/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning.
@@ -140,9 +142,10 @@ pub fn layered_ai_from(
visible.opened_artboard = active_page(active, &visible);
}
let have = visible.patterns.len();
use_palette(&data, &mut visible);
let own_palette = use_palette(&data, &mut visible);
use_brushes(&data, &mut visible, have, &mut warnings);
use_styles(&data, &mut visible, false, unit, Some(&boards));
use_symbols(&data, &mut visible, own_palette, &mut warnings);
use_variables(&data, &mut visible, &mut warnings);
return (visible, warnings, false);
}
@@ -152,10 +155,11 @@ pub fn layered_ai_from(
let (boards, active) = ai::artboards(&data);
place_pages(&boards, active, &mut visible, unit);
let have = visible.patterns.len();
use_palette(&data, &mut visible);
let own_palette = use_palette(&data, &mut visible);
use_brushes(&data, &mut visible, have, &mut warnings);
use_styles(&data, &mut visible, false, unit, Some(&boards));
let mut all = unread(warnings, why);
use_symbols(&data, &mut visible, own_palette, &mut all);
use_variables(&data, &mut visible, &mut all);
(visible, all, false)
};
@@ -246,14 +250,14 @@ fn use_variables(data: &[u8], doc: &mut Document, warnings: &mut Vec<String>) {
/// palette doesn't have follow the palette's swatches. The patterns that the editing data defines come
/// in for the palette's pattern swatches, with the images they show; one whose name a pattern of
/// the PDF part already has takes a free variant of it ([`ai::palette`]). Left as
/// it is when the palette is empty.
fn use_palette(data: &[u8], doc: &mut Document) {
let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return };
/// it is when the palette is empty. Whether the palette's swatches came in.
fn use_palette(data: &[u8], doc: &mut Document) -> bool {
let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return false };
for (key, blob) in images {
doc.images.entry(key).or_insert(blob);
}
for mut p in patterns {
p.art = p.art.iter().map(|n| Arc::new(doc.reid(n))).collect();
p.art = p.art.iter().map(|n| Arc::new(reid_page_art(doc, n))).collect();
doc.patterns.push(p);
}
let named = |name: &str| swatches.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|s| s.name == name);
@@ -262,6 +266,7 @@ fn use_palette(data: &[u8], doc: &mut Document) {
let mut swatches = swatches;
swatches.extend(art);
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
true
}
/// `doc` (a file read as its page or PDF part, in place of its layers) with the brushes of its
@@ -280,7 +285,7 @@ fn use_brushes(data: &[u8], doc: &mut Document, have: usize, warnings: &mut Vec<
if doc.pattern(&p.name).is_some() {
continue;
}
p.art = p.art.iter().map(|n| Arc::new(doc.reid(n))).collect();
p.art = p.art.iter().map(|n| Arc::new(reid_page_art(doc, n))).collect();
doc.patterns.push(p);
}
vectorcraft_brush::store(doc, &brushes);
@@ -320,6 +325,66 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo
doc.graphic_styles = styles.into();
}
/// `doc` (a file read as its page or PDF part, in place of its layers) with the symbols of its
/// editing data `data` ([`ai::symbols`]), their registration points, the images their art shows and
/// the global and spot colors it is linked to that `doc` has no such swatch for (a color whose name
/// another swatch has takes that swatch's place when it is a default swatch and not `own_palette`,
/// the swatches being those of the editing data's palette, and else comes in under a free variant
/// of the name, which the art then names, as does one whose name a color group has), as [`build`] gives a document read from the layers, and
/// the note about instances that can't be drawn added to `warnings`. An object of their art linked
/// to a graphic style keeps the link when [`use_styles`] gave `doc` the editing data's styles. The
/// symbols' art keeps the editing data's units, as in a document read from the layers: when
/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art
/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has
/// none this reads.
fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec<String>) {
if !doc.symbols.is_empty() {
return;
}
let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data) else { return };
for (key, blob) in images {
doc.images.entry(key).or_insert(blob);
}
// A color whose name a swatch of another color has takes that swatch's place when it is a
// default swatch, and else a free variant of the name, as does one whose name a color group has.
let (defaults, groups) = vectorcraft_color::default_swatches(doc.color_mode.model());
let is_default =
|have: &vectorcraft_color::Swatch| !own_palette && defaults.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|d| d == have);
let mut renamed = std::collections::HashMap::new();
for s in swatches {
match doc.swatch(&s.name) {
None if !doc.swatch_name_taken(&s.name) => doc.swatches.push(s),
Some(have) if have == &s || (s.name.starts_with('[') && s.name.ends_with(']')) => {}
Some(have) if is_default(have) => {
if let Some(default) = doc.swatch_mut(&s.name) {
*default = s;
}
}
_ => {
let name = doc.free_swatch_name(&s.name);
renamed.insert(s.name.clone(), name.clone());
doc.swatches.push(vectorcraft_color::Swatch { name, ..s });
}
}
}
let mut follow = |_: &mut vectorcraft_color::Color, link: &mut Option<String>, _: &mut f32| {
let Some(new) = link.as_ref().and_then(|l| renamed.get(l)) else { return false };
*link = Some(new.clone());
true
};
for (mut symbol, registration) in symbols {
let mut art = reid_page_art(doc, &symbol.art);
if !renamed.is_empty() {
vectorcraft_doc::swatches::map_node_solid_paints(&mut art, &mut follow);
}
symbol.art = Arc::new(art);
doc.set_symbol_registration(&symbol.name, registration);
doc.symbols.push(symbol);
}
// Symbols that hold instances of themselves can't be drawn: those instances go.
doc.limit_depth_noting(warnings);
}
/// How many points a unit of `boards` (the artboards of a file's editing data, in art space) is in
/// `doc` (the file read as its PDF part: a page per artboard): 1 when there are as many artboards as
/// pages, each the size of its page in points, or `unit` when `unit` is above 1 and each is the size
+86
View File
@@ -1462,3 +1462,89 @@ fn bitshift_by_the_most_negative_integer_does_not_panic() {
let r = read("0 0 10 10 rectfill 1 -9223372036854775808 bitshift pop -1 -9223372036854775808 bitshift pop");
assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings);
}
/// The stops (offset, grey's black ink or red) and the start and end of the linear gradient `n` is
/// filled with.
fn linear_stops(n: &Node) -> (Vec<(f32, f32)>, Point, Point) {
let Some(Paint::Gradient(g)) = fill(n) else { panic!("not a gradient: {:?}", fill(n)) };
assert_eq!(g.gradient.kind, GradientKind::Linear);
let level = |c: &Color| match *c {
Color::Gray { k } => k,
Color::Rgb { r, .. } => r,
other => panic!("{other:?}"),
};
let geom = g.geom.unwrap();
(g.gradient.stops.iter().map(|s| (s.offset, level(&s.color))).collect(), geom.start, geom.end)
}
#[test]
fn abutting_strips_of_a_gradient_become_one_gradient() {
// A grey ramp drawn right to left as 40 strips 2 pt wide under a clip, after a strip of no
// width on its right edge: the clip path filled with one linear gradient, without seams.
let r = read(
"gsave 10 10 80 80 rectclip 90 10 0 80 rectfill
0 1 39 { /i exch def i 39 div setgray 88 i 2 mul sub 10 2 80 rectfill } for grestore",
);
let o = objects(&r.document);
assert_eq!(o.len(), 1, "{o:#?}");
let n = &o[0];
assert!(matches!(n.kind, NodeKind::Path { .. }));
assert!(near(bounds(n), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(n));
let (stops, start, end) = linear_stops(n);
// A straight ramp needs only its two ends, at the middles of the end strips: black on the
// right, white on the left.
assert_eq!(stops.len(), 2, "{stops:?}");
let x = |t: f32| start.x + (end.x - start.x) * f64::from(t);
assert!((start.y - end.y).abs() < 1e-6);
let ((t0, k0), (t1, k1)) = (stops[0], stops[1]);
// A grey's level is its black ink: 1 for black.
let (dark, light) = if k0 > k1 { ((x(t0), k0), (x(t1), k1)) } else { ((x(t1), k1), (x(t0), k0)) };
assert!((dark.0 - 89.0).abs() < 0.01 && (dark.1 - 1.0).abs() < 1e-6, "{dark:?}");
assert!((light.0 - 11.0).abs() < 0.01 && light.1.abs() < 1e-6, "{light:?}");
}
#[test]
fn strips_under_a_larger_clip_keep_the_clip() {
// The same ramp under a clip larger than the strips: the gradient fills only where the
// strips were, still clipped, not the whole clip.
let r = read(
"gsave 5 5 90 90 rectclip
0 1 39 { /i exch def i 39 div setgray 88 i 2 mul sub 10 2 80 rectfill } for grestore",
);
let o = objects(&r.document);
assert_eq!(o.len(), 1, "{o:#?}");
let NodeKind::Group { children, clip: true } = &o[0].kind else { panic!("not a clipping group: {:#?}", o[0]) };
let [_, fill] = children.as_slice() else { panic!("{children:#?}") };
assert!(near(bounds(fill), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(fill));
let (stops, _, _) = linear_stops(fill);
assert_eq!(stops.len(), 2, "{stops:?}");
}
#[test]
fn turned_strips_become_a_gradient_across_them() {
// Strips drawn in a turned space: the gradient runs square to them.
let r = read("50 50 translate 30 rotate 0 1 19 { /i exch def i 19 div 0 0 setrgbcolor i 2 mul 20 sub -20 2 40 rectfill } for");
let o = objects(&r.document);
assert_eq!(o.len(), 1, "{o:#?}");
let (stops, start, end) = linear_stops(&o[0]);
assert_eq!(stops.len(), 2, "{stops:?}");
let d = end - start;
// 30° in PostScript's y-up space is −30° in the document's y-down one.
let angle = d.y.atan2(d.x).to_degrees();
assert!((angle + 30.0).abs() < 0.01 || (angle - 150.0).abs() < 0.01, "{angle}");
assert!((d.hypot() - 40.0).abs() < 0.01, "{}", d.hypot());
}
#[test]
fn abutting_blocks_of_distinct_colours_stay_apart() {
// Side by side but each a different colour (a row of swatches): not a gradient.
let blocks: String =
(0..8).map(|i| format!("{} {} {} setrgbcolor {} 10 10 80 rectfill ", i % 2, (i / 2) % 2, (i / 4) % 2, 10 + i * 10)).collect();
assert_eq!(objects(&read(&blocks).document).len(), 8);
// Too few strips to be sure of.
let few: String = (0..5).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 2)).collect();
assert_eq!(objects(&read(&few).document).len(), 5);
// Strips with gaps between them.
let gaps: String = (0..10).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 3)).collect();
assert_eq!(objects(&read(&gaps).document).len(), 10);
}
+255
View File
@@ -1437,6 +1437,37 @@ fn an_ai_file_without_layers_keeps_the_patterns_of_its_palette() {
assert!(keys.iter().all(|k| d.images.contains_key(k)), "{keys:?}");
}
/// The art of a pattern with an opacity mask that a palette swatch or a brush's art paints with,
/// in a file without layers, takes ids of the document, its mask's art included.
#[test]
fn a_pattern_of_a_file_without_layers_takes_ids_for_its_mask_art() {
let mask_art: String = symbol_square(0.0, 0.0, 4.0, 4.0).replace("0 1 1 0 1 0 0 Xa", "1 g").lines().map(|l| format!("%_{l}\n")).collect();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
let hidden = |art: String| -> String { art.lines().map(|l| format!("%_{l}\n")).collect() };
let tile = format!("{}{mask}{}", hidden(symbol_square(0.0, 0.0, 4.0, 4.0)), hidden(symbol_square(1.0, 1.0, 3.0, 3.0)));
let dots = format!("%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n{tile}E\n%AI3_EndPattern\n");
let palette = "%AI5_BeginPalette\n0 0 Pb\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots)\nPc\nPB\n%AI5_EndPalette\n";
let order = "%AI8_BeginPluginObject\n(Adobe Brush Manager Order)\n(Adobe Brush Manager Order)\n( Adobe Scatter Brush Tool/ Spray/) .\n%AI8_EndPluginObject\n";
let spray = "%AI8_BeginPluginObject\n(Adobe Scatter Brush Tool)\n(Spray)\n(2 /Dot art/ 5 1 0 0 / 0 0.5 1.5 1 ) -\n\
(1.2 2 0 -0.25 0.25 2 -45 30 1 1 0) .\n%AI8_EndPluginObject\n";
let dot = "%AI8_BeginBrushPattern\n(Dot art)\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 0 m\n4 0 L\n4 4 L\nf\n%AI8_EndBrushPattern\n";
for setup in [format!("{dots}{palette}"), format!("{dots}{dot}{order}{spray}")] {
let library = format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{setup}%%EndSetup\n%%PageTrailer\n%%Trailer\n");
let (d, warnings, read) = crate::layered_ai(&private(&library), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert!(!read, "{warnings:?}");
let art = d.pattern("Dots").map(|p| p.art.clone()).unwrap_or_default();
let mut masked = art.iter().flat_map(|n| std::iter::once(n).chain(n.children().into_iter().flatten()));
assert!(masked.any(|n| n.extras().mask.is_some()), "the mask is read: {art:#?} {warnings:?}");
let mut ids = vec![];
for n in d.layers.iter().chain(d.patterns.iter().flat_map(|p| &p.art)) {
all_ids(n, &mut ids);
}
let unique: std::collections::HashSet<_> = ids.iter().copied().collect();
assert_eq!(unique.len(), ids.len(), "{ids:?}");
assert!(ids.iter().all(|id| id.0 < d.peek_next_id()), "{ids:?}");
}
}
/// A brush library (brush objects and their art, no layers) comes in as its PDF part with the
/// library's brushes in place of the default ones: Other Library lists them.
#[test]
@@ -1567,6 +1598,230 @@ fn a_file_whose_layers_cant_be_used_keeps_its_graphic_styles() {
assert_eq!(names(&r.document), ["Thin", "Medium", "Thick"], "{:?}", r.warnings);
}
/// A symbol named `name` whose art is `art` (in the symbol's space, y down), with the registration
/// point `registration` (1 to 9 from the top left, row by row), as the editing data defines it.
fn symbol_def(name: &str, art: &str, registration: u32) -> String {
format!(
"%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n{art}U\n%_/ArtDictionary :\n%_{registration} /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"
)
}
/// A red square from (`x0`, `y0`) to (`x1`, `y1`), as a symbol's art.
fn symbol_square(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
format!("0 1 1 0 1 0 0 Xa\n{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\nf\n")
}
/// The symbols Dot (a square of 4 about the origin, registered at its top left), Bar (12 × 2 about
/// the origin) and Masked (a square with an opacity mask), listed as Bar, Masked, Dot.
fn symbol_defs() -> String {
let mask_art: String = symbol_square(-2.0, -2.0, 2.0, 2.0).replace("0 1 1 0 1 0 0 Xa", "1 g").lines().map(|l| format!("%_{l}\n")).collect();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n");
let masked = format!("{}{mask}{}", symbol_square(-2.0, -2.0, 2.0, 2.0), symbol_square(-1.0, -1.0, 1.0, 1.0));
let defs = symbol_def("Dot", &symbol_square(-2.0, -2.0, 2.0, 2.0), 1)
+ &symbol_def("Bar", &symbol_square(-6.0, -1.0, 6.0, 1.0), 5)
+ &symbol_def("Masked", &masked, 5);
format!("%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n%AI24_BeginSymbolList\n(Bar)\n(Masked)\n(Dot)\n%AI24_EndSymbolList\n")
}
/// A symbol library (the symbols of [`symbol_defs`], and no layers).
fn symbol_library() -> String {
format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{}%%EndSetup\n%%PageTrailer\n%%Trailer\n", symbol_defs())
}
/// The ids of `n`, what it holds and the art of its opacity masks.
fn all_ids(n: &Node, out: &mut Vec<vectorcraft_doc::NodeId>) {
out.push(n.id);
if let Some(m) = n.extras().mask.as_ref() {
all_ids(&m.art, out);
}
for c in n.children().into_iter().flatten() {
all_ids(c, out);
}
}
/// The names of the symbols of `d`, in the Symbols panel's order.
fn symbol_names(d: &Document) -> Vec<&str> {
d.symbols.iter().map(|s| s.name.as_str()).collect()
}
/// A symbol library comes in as its PDF part with the library's symbols, in the list's order, with
/// their registration points: Other Library lists them. Their art takes ids of the document, the
/// art of an opacity mask in it included.
#[test]
fn an_ai_symbol_library_without_layers_keeps_its_symbols() {
let (d, warnings, read) = crate::layered_ai(&private(&symbol_library()), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert!(!read, "{warnings:?}");
assert_eq!(symbol_names(&d), ["Bar", "Masked", "Dot"], "{warnings:?}");
assert_eq!(d.symbol_registration("Dot"), vectorcraft_doc::SymbolRegistration(0));
assert!(d.symbol_registration("Bar").is_center());
let boxes = d.symbol_boxes();
let bar = d.symbols.first().and_then(|s| s.art.visual_bounds_with(&boxes));
assert_eq!(bar, Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)));
drop(boxes);
let mut ids = vec![];
for n in d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)) {
all_ids(n, &mut ids);
}
let masked = d.symbols.get(1).map(|s| &s.art);
assert!(masked.is_some_and(|a| a.children().into_iter().flatten().any(|c| c.extras().mask.is_some())), "the mask is read");
let unique: std::collections::HashSet<_> = ids.iter().copied().collect();
assert_eq!(unique.len(), ids.len(), "{ids:?}");
assert!(warnings.is_empty(), "{warnings:?}");
// Without symbols, the PDF part's (none) stay.
let (d, warnings, _) = crate::layered_ai(&private(&native("")), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert!(d.symbols.is_empty(), "{warnings:?}");
}
/// An EPS whose editing copy has symbols and no layers comes in as its page with those symbols.
#[test]
fn an_eps_without_layers_keeps_its_symbols() {
let r = import(&eps(&page_square(50, 50), &symbol_library())).unwrap();
assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings);
}
/// A `.ai` that comes in as its PDF part, and an EPS that comes in as its page, because their
/// layers can't be used (their editing data is for another page) get the symbols defined before
/// their layers.
#[test]
fn a_file_whose_layers_cant_be_used_keeps_its_symbols() {
let art = native(&format!("{}{}", symbol_defs(), layer("Art", true, &square(10, 10)))).replace(" 0 0 100 100", " 0 0 600 400");
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(!read && warnings.iter().any(|w| w.contains("600 × 400")), "{warnings:?}");
assert_eq!(symbol_names(&d), ["Bar", "Masked", "Dot"], "{warnings:?}");
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("600 × 400")), "{:?}", r.warnings);
assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings);
}
/// A symbol library's symbols bring the spot colors their art is linked to. A color whose name a
/// swatch of the library's palette has for another color comes in under a free variant of the
/// name, which the art then names, as it does for an ink of the PDF part; without a palette, it
/// takes the place of the default swatch of that name.
#[test]
fn an_ai_symbol_librarys_symbols_keep_their_spot_colors() {
let library = |setup: &str, ink: &str| {
let art = format!("0 0 1 0 ({ink}) 1 x\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n");
let defs = format!("{setup}%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def("Inked", &art, 5));
format!("%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{defs}%%EndSetup\n%%PageTrailer\n%%Trailer\n")
};
let open = |text: &str| crate::layered_ai(&private(text), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
let link = |d: &Document| match d.symbols.first().and_then(|s| s.art.appearance.fill()).map(|f| &f.paint) {
Some(vectorcraft_color::Paint::Solid { swatch, .. }) => swatch.clone(),
_ => None,
};
let ink = |d: &Document, name: &str| d.swatch(name).map(|s| (s.paint.color(), s.spot));
let yellow = Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0));
let (d, warnings, _) = open(&library("", "Brand Ink"));
assert_eq!(link(&d).as_deref(), Some("Brand Ink"), "{warnings:?}");
assert_eq!(ink(&d, "Brand Ink"), Some((yellow, true)));
let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 0 1 0 Xa\n(Clash)\nPc\nPB\n%AI5_EndPalette\n";
let (d, warnings, _) = open(&library(palette, "Clash"));
assert_eq!(link(&d).as_deref(), Some("Clash 2"), "{warnings:?}");
assert_eq!(ink(&d, "Clash 2"), Some((yellow, true)));
assert_eq!(ink(&d, "Clash"), Some((Some(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)), false)));
let (d, warnings, _) = open(&library("", "Red"));
assert_eq!(link(&d).as_deref(), Some("Red"), "{warnings:?}");
assert_eq!(ink(&d, "Red"), Some((yellow, true)));
assert_eq!(d.swatches.iter().filter(|s| s.name == "Red").count(), 1);
// An ink of the PDF part keeps its swatch: the library's color of that name is renamed.
let mut part = pdf_part(10.0, 86.0);
let green = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 1.0, 0.0));
part.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: green.clone(), global: true, spot: true });
let (d, warnings, _) = crate::layered_ai(&private(&library("", "Ink")), part, vec![], false, true, 1.0);
assert_eq!(link(&d).as_deref(), Some("Ink 2"), "{warnings:?}");
assert_eq!(ink(&d, "Ink 2"), Some((yellow, true)));
assert_eq!(d.swatch("Ink").map(|s| &s.paint), Some(&green));
// A color group of the PDF part keeps its name: the library's color of that name is renamed.
let mut part = pdf_part(10.0, 86.0);
part.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Kit".into(), swatches: vec![] });
let (d, warnings, _) = crate::layered_ai(&private(&library("", "Kit")), part, vec![], false, true, 1.0);
assert_eq!(link(&d).as_deref(), Some("Kit 2"), "{warnings:?}");
assert_eq!(ink(&d, "Kit 2"), Some((yellow, true)));
assert!(d.swatch("Kit").is_none());
}
/// A symbol library's symbols bring a global process color their art is filled with as a global
/// swatch that isn't an ink, and a spot color a stop of their gradient names as an ink.
#[test]
fn an_ai_symbol_librarys_symbols_keep_their_global_and_gradient_stop_colors() {
let gradient = "%AI5_BeginGradient: (Fade)\n(Fade) 0 2 Bd\n[\n\
%_0 1 1 0 (Brand) 0 0 5 1 6 50 0 Bs\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let global = "0 0 1 0 (Process Yellow) 0 0 Xk\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n";
let graded = "Bb\n1 (Fade) 0 0 0 4 1 0 0 1 0 0 Bg\n-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\nf\n0 BB\n";
let defs = symbol_def("Global", global, 5) + &symbol_def("Graded", graded, 5);
let text = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%BeginSetup\n{gradient}%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n\
%%EndSetup\n%%PageTrailer\n%%Trailer\n"
);
let (d, warnings, _) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert_eq!(symbol_names(&d), ["Global", "Graded"], "{warnings:?}");
let swatch = |name: &str| d.swatch(name).map(|s| (s.paint.color(), s.global, s.spot));
assert_eq!(swatch("Process Yellow"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)), true, false)), "{warnings:?}");
assert_eq!(swatch("Brand"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, true)), "{warnings:?}");
}
/// A symbol library's symbol shows its image from the document's images, and an object of a
/// symbol's art linked to a style of the library's style list links to that style as the document
/// has it.
#[test]
fn an_ai_symbol_librarys_symbols_bring_their_images_and_style_links() {
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
let m = "[ 5 0 0 5 -8096 8244.5 ]";
let raster = format!(
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\
%%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n"
);
let path = "-2 -2 m\n-2 2 L\n2 2 L\n2 -2 L\n-2 -2 L\n";
let hidden: String = path.lines().map(|l| format!("%_{l}\n")).collect();
let styled = format!("0 Ae\nu\n0 0 0 0 0 0 1 XA\n0 J 0 j 3 w 10 M []0 d\n{path}S\nU\n{hidden}%_S\n1 (Thick) XW\n");
let defs = format!("%AI5_Begin_NonPrinting\n{}{}%AI5_End_NonPrinting--\n", symbol_def("Pic", &raster, 5), symbol_def("Line", &styled, 5));
let text = style_library(&defs).replacen("%%BeginSetup", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%BeginSetup", 1);
let (d, warnings, read) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert!(!read, "{warnings:?}");
assert_eq!(symbol_names(&d), ["Pic", "Line"], "{warnings:?}");
let key = d.symbols.first().and_then(|s| if let NodeKind::Image(im) = &s.art.kind { Some(im.key.clone()) } else { None });
let key = key.unwrap_or_else(|| panic!("an image: {:#?}", d.symbols.first()));
assert!(d.images.contains_key(&key), "{key}");
let mut linked = vec![];
let mut stack: Vec<&Node> = d.symbols.get(1).map(|s| &*s.art).into_iter().collect();
while let Some(n) = stack.pop() {
linked.extend(n.extras().graphic_style);
stack.extend(n.children().into_iter().flatten().map(|c| &**c));
}
let named: Vec<&str> = linked.iter().filter_map(|id| d.graphic_styles.iter().find(|g| g.id == *id)).map(|g| g.name.as_str()).collect();
assert_eq!(named, ["Thick"], "{linked:?}");
}
/// The instances of a symbol library's symbols that would draw more than
/// [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`] objects deep go, with the note saying so, and a
/// document that has symbols already keeps them.
#[test]
fn an_ai_symbol_librarys_instances_drawn_too_deep_go() {
// C0 is a square, and each later symbol holds an instance of the one before it. The template
// box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
let instance = |name: &str| format!("/SymbolInstance :\n({name}) /SymbolRef ,\n1 0 0 -1 -8091 8242 /RTransformMatrix\n;\n9 () XW\n");
let count = vectorcraft_doc::depth::MAX_DRAWN_DEPTH + 10;
let mut defs = symbol_def("C0", &symbol_square(-2.0, -2.0, 2.0, 2.0), 5);
for i in 1..count {
defs.push_str(&symbol_def(&format!("C{i}"), &instance(&format!("C{}", i - 1)), 5));
}
let text = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%BeginSetup\n\
%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n%%EndSetup\n%%PageTrailer\n%%Trailer\n"
);
let (d, warnings, _) = crate::layered_ai(&private(&text), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
assert_eq!(d.symbols.len(), count, "{warnings:?}");
let instances = d.symbols.iter().filter(|s| matches!(s.art.kind, NodeKind::SymbolInstance { .. })).count();
assert!(instances < count - 1, "{instances}");
assert!(warnings.contains(&vectorcraft_doc::depth::symbols_note()), "{warnings:?}");
// A document with symbols keeps its own.
let mut part = pdf_part(10.0, 86.0);
let mine = Node::group(part.alloc_id(), vec![]);
part.symbols.push(vectorcraft_doc::Symbol { name: "Mine".into(), art: std::sync::Arc::new(mine), dynamic: false });
let (d, warnings, _) = crate::layered_ai(&private(&text), part, vec![], false, true, 1.0);
assert_eq!(symbol_names(&d), ["Mine"], "{warnings:?}");
}
/// A PDF part whose tiling has the name of a pattern its editing data defines (the PDF part names
/// its tilings `Pattern 1`, `Pattern 2` and so on): the editing data's pattern comes in under a free
/// variant of the name, and the palette's swatch and the brush's art paint with it, not with the