mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 01:20:09 -06:00
.ai import: the document's graphic styles open in the panel with their objects linked
Squashed from #5155 onto main.
This commit is contained in:
@@ -160,6 +160,36 @@ fn an_ai_file_opens_with_the_swatches_of_its_palette() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A `.ai` opens with the graphic styles of its style list, in the list's order, and an object read
|
||||
/// as itself with a listed style is linked to it.
|
||||
#[test]
|
||||
fn an_ai_file_opens_with_the_graphic_styles_of_its_style_list() {
|
||||
let style = |name: &str, xa: &str| {
|
||||
format!(
|
||||
"/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n"
|
||||
)
|
||||
};
|
||||
let styles = format!(
|
||||
"%AI9_BeginArtStyles\n{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Red)\n(Blue)\n%AI9_EndArtStyleList\n",
|
||||
style("Blue", "0 0 0 0 0 0 1 Xa\n"),
|
||||
style("Red", "0 0 0 0 1 0 0 Xa\n")
|
||||
);
|
||||
let hidden = ai::rect(10.0, 50.0, 40.0, 90.0).replace('\n', "\n%_");
|
||||
let art = format!("0 Ae\nu\n0 0 0 0 1 0 0 Xa\n{}f\nU\n%_{hidden}f\n1 (Red) XW\n", ai::rect(10.0, 50.0, 40.0, 90.0));
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("Art", &art)));
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "styles.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(names(&doc.layers), ["Art"], "opened from the editing data: {r}");
|
||||
let styles: Vec<(&str, u32)> = doc.graphic_styles.iter().map(|g| (g.name.as_str(), g.id)).collect();
|
||||
assert_eq!(styles, [("Red", 1), ("Blue", 2)]);
|
||||
let square = &doc.layers[0].children().unwrap()[0];
|
||||
assert_eq!(square.extras().graphic_style, Some(1), "{square:#?}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose editing data has a palette and no layers, as a swatch library is saved, opens as its
|
||||
/// PDF part with the swatches and color groups of its palette in place of the default swatches.
|
||||
#[test]
|
||||
|
||||
@@ -3230,6 +3230,46 @@ proptest! {
|
||||
survive("mutated .ai Objects on Path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// Style lists (`%AI9_BeginArtStyleList`) of any entries, repeated, empty, malformed or not
|
||||
/// defined, with or without their end, and objects whose `XW` has any code and any style name.
|
||||
#[test]
|
||||
fn ai_style_lists_and_style_links_never_panic(
|
||||
listed in prop::collection::vec(prop_oneof![
|
||||
prop::sample::select(vec!["(A)", "(B)", "()", "(Caf\\303\\251)", "(A(B))", "(x\\)", "(", ")", "<41>", "/A", "1"]).prop_map(str::to_string),
|
||||
"[ -~]{0,12}",
|
||||
], 0..12),
|
||||
ended in any::<bool>(),
|
||||
objects in prop::collection::vec((prop_oneof![arb_num(), prop::sample::select(vec!["1".to_string(), "6".into(), "9".into()])], prop::sample::select(vec!["A", "B", "", "Caf\u{e9}", "Missing"]), any::<bool>()), 0..6),
|
||||
) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let style = |name: &str| {
|
||||
format!(
|
||||
"/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 1 0 0 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||||
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n; /Part ,\n;\n"
|
||||
)
|
||||
};
|
||||
let end = if ended { "%AI9_EndArtStyleList\n" } else { "" };
|
||||
let header = format!(
|
||||
"%AI9_BeginArtStyles\n{}{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n{}\n{end}",
|
||||
style("A"),
|
||||
style("B"),
|
||||
style("Caf\u{e9}"),
|
||||
listed.join("\n")
|
||||
);
|
||||
let art: String = objects
|
||||
.iter()
|
||||
.map(|(code, name, group)| {
|
||||
let object = if *group { "%_0 Ae\n%_u\n%_0 0 m\n%_0 9 L\n%_9 9 L\n%_f\n%_U\n" } else { "%_0 0 m\n%_0 9 L\n%_9 9 L\n%_f\n" };
|
||||
format!("0 Ae\nu\n0 0 m\n0 9 L\n9 9 L\nf\nU\n{object}{code} ({name}) XW\n")
|
||||
})
|
||||
.collect();
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("{header}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{art}LB\n%AI5_EndLayer--\n"));
|
||||
survive("style lists and links", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
let eps = ai::eps(data.as_bytes(), ai::page_ps());
|
||||
survive("style lists and links in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
/// Objects on Path records of any shape: ids in any order, repeated or missing, any number of
|
||||
/// objects, pivots and angles out of range or not finite, spacings, order tags that disagree,
|
||||
/// groups nested or cut short, and versioned content left open.
|
||||
@@ -3662,6 +3702,18 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"9 () XW",
|
||||
"\n%AI9_BeginArtStyles\n",
|
||||
"\n%AI9_EndArtStyles\n",
|
||||
"\n%AI9_BeginArtStyleList\n",
|
||||
"\n%AI9_EndArtStyleList\n",
|
||||
"(style)\n",
|
||||
"(st\\)y(le)\n",
|
||||
"(\\303\\251",
|
||||
"(style) XW",
|
||||
"1 () XW",
|
||||
"6 (style) XW",
|
||||
"7 (style) XW",
|
||||
"-1 (style) XW",
|
||||
"1e308 (style) XW",
|
||||
"1 (Caf\\351) XW",
|
||||
"/KnownStyle :",
|
||||
"(style) /Name ,",
|
||||
"/ActiveStyle :",
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
//! - Swatches: the palette (`%AI5_BeginPalette`), read by [`super::palette`], replaces the
|
||||
//! default swatches.
|
||||
//!
|
||||
//! - Graphic styles: the style list (`%AI9_BeginArtStyleList`), one `(name)` per style in the
|
||||
//! panel's order, each the name of an art style. The listed styles whose appearance reads
|
||||
//! ([`appearance_from_art_style`](ops::appearance_from_art_style)) replace the default graphic
|
||||
//! styles, and an object read as itself with a listed style (`1 (name) XW`) that has the
|
||||
//! style's appearance and transparency is linked to it. A `1` naming a style the list doesn't
|
||||
//! have is the object's own appearance, not a link.
|
||||
//!
|
||||
//! Sections the reader doesn't need (brushes, symbols, styles, filters) are skipped;
|
||||
//! type, symbols, fills with a pattern it can't read, placed files without a preview and operators
|
||||
//! it doesn't know are errors.
|
||||
@@ -140,6 +147,8 @@ pub struct Structure {
|
||||
/// The live envelopes read from the editing data, by id, with the drawn art each took the place
|
||||
/// of: the importer puts that art back when the layers with the envelopes don't draw as the page.
|
||||
pub envelopes_drawn: Vec<(NodeId, Node)>,
|
||||
/// The document's graphic styles are those of the file's style list, in place of the defaults.
|
||||
pub styles_read: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -277,6 +286,11 @@ struct Reader<'a> {
|
||||
mesh: Option<(super::mesh::MeshReader, bool)>,
|
||||
/// The art styles (`%AI9_BeginArtStyles`), by name.
|
||||
styles: HashMap<String, super::style::ArtStyle>,
|
||||
/// The style list (`%AI9_BeginArtStyleList`): the Graphic Styles panel's names, in its order.
|
||||
style_list: Vec<Vec<u8>>,
|
||||
/// The objects read as themselves with the style their `1 (style) XW` names: their id and the
|
||||
/// style's name, as written.
|
||||
style_links: Vec<(NodeId, String)>,
|
||||
/// The swatches (`%AI5_BeginPalette`).
|
||||
palette: super::palette::Palette,
|
||||
/// See [`Structure::restyled`].
|
||||
@@ -394,6 +408,8 @@ impl<'a> Reader<'a> {
|
||||
layer_state: None,
|
||||
mesh_undo: None,
|
||||
styles: HashMap::new(),
|
||||
style_list: vec![],
|
||||
style_links: vec![],
|
||||
palette: super::palette::Palette::default(),
|
||||
restyled: vec![],
|
||||
placed_elsewhere: vec![],
|
||||
|
||||
@@ -98,6 +98,11 @@ impl<'a> Reader<'a> {
|
||||
self.styles = crate::import::ai::style::art_styles(section);
|
||||
return Ok(());
|
||||
}
|
||||
if m == "BeginArtStyleList" {
|
||||
let section = self.section("EndArtStyleList")?;
|
||||
self.style_list = crate::import::ai::style::art_style_list(section);
|
||||
return Ok(());
|
||||
}
|
||||
if m == "BeginPalette" {
|
||||
let section = self.section("EndPalette")?;
|
||||
self.palette = crate::import::ai::palette::palette(section);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use super::art::{element_name, elements, join_and, sane};
|
||||
use super::containers::drop_commented_copies;
|
||||
use super::ops::appearance_from_art_style;
|
||||
use super::types::{FLIP, Kind, MAX_DEPTH};
|
||||
use super::{
|
||||
Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure,
|
||||
Swatch, Unit, V, obj,
|
||||
Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, HashMap, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect,
|
||||
Structure, Swatch, Unit, V, obj,
|
||||
};
|
||||
use vectorcraft_doc::{Guide, ImageBlob, PatternDef, TextWrap};
|
||||
use vectorcraft_doc::{GraphicStyle, Guide, ImageBlob, PatternDef, TextWrap};
|
||||
|
||||
/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files).
|
||||
fn ruler_unit(v: u8) -> Option<Unit> {
|
||||
@@ -528,6 +529,35 @@ impl<'a> Reader<'a> {
|
||||
Some((swatches, groups))
|
||||
}
|
||||
|
||||
/// The graphic styles of the style list, in its order: each listed name the art styles define with
|
||||
/// an appearance [`appearance_from_art_style`] reads, once (a name listed again, empty or not
|
||||
/// defined is left out), with ids from 1. Also the id of each kept name.
|
||||
fn graphic_styles(&self) -> (Vec<GraphicStyle>, HashMap<String, u32>) {
|
||||
let mut styles: Vec<GraphicStyle> = vec![];
|
||||
let mut ids = HashMap::new();
|
||||
for raw in &self.style_list {
|
||||
let name = obj::text(raw);
|
||||
if name.is_empty() || ids.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
let Some(st) = self.styles.get(&name) else { continue };
|
||||
let Some(mut appearance) = appearance_from_art_style(st) else { continue };
|
||||
// A Large Canvas copy's styles are at its art's scale ([`Self::ensure_space`]).
|
||||
if let Some(s) = self.large_canvas.filter(|s| *s != 1.0) {
|
||||
appearance.scale_strokes(s);
|
||||
appearance.scale_effects(s, vectorcraft_effects::scale_effect);
|
||||
}
|
||||
let id = u32::try_from(styles.len() + 1).unwrap_or(u32::MAX);
|
||||
ids.insert(name.clone(), id);
|
||||
let mut style = GraphicStyle { id, ..GraphicStyle::new(name, appearance) };
|
||||
if let Some((opacity, blend, isolate, knockout)) = st.transparency {
|
||||
(style.opacity, style.blend, style.isolate, style.knockout) = (opacity, blend, isolate, knockout);
|
||||
}
|
||||
styles.push(style);
|
||||
}
|
||||
(styles, ids)
|
||||
}
|
||||
|
||||
pub(super) fn finish(mut self) -> Result<Structure, String> {
|
||||
if self.graph.take().is_some() {
|
||||
self.warn(super::types::GRAPH_UNENDED);
|
||||
@@ -648,6 +678,16 @@ impl<'a> Reader<'a> {
|
||||
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
|
||||
}
|
||||
}
|
||||
// The file's graphic styles in place of the defaults, and the objects linked to them.
|
||||
let (styles, ids) = self.graphic_styles();
|
||||
let styles_read = !styles.is_empty();
|
||||
if styles_read {
|
||||
let links: HashMap<NodeId, u32> = self.style_links.iter().filter_map(|(node, name)| Some((*node, *ids.get(name)?))).collect();
|
||||
if !links.is_empty() {
|
||||
link_styles(&mut doc.layers, &links, &styles, 0);
|
||||
}
|
||||
doc.graphic_styles = styles.into();
|
||||
}
|
||||
let mut warnings = self.warnings;
|
||||
if self.drawn_looks > 0 {
|
||||
warnings.push(format!(
|
||||
@@ -669,11 +709,41 @@ impl<'a> Reader<'a> {
|
||||
restyled: self.restyled,
|
||||
placed_elsewhere: self.placed_elsewhere,
|
||||
envelopes_drawn: self.envelopes_drawn,
|
||||
styles_read,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Link the objects of `links` among `nodes` to their graphic style's id, each only when it has its
|
||||
/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order). A path, which
|
||||
/// has no contents, takes the style's contents slot.
|
||||
fn link_styles(nodes: &mut [Arc<Node>], links: &HashMap<NodeId, u32>, styles: &[GraphicStyle], depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in nodes {
|
||||
if let Some(id) = links.get(&n.id)
|
||||
&& let Some(g) = id.checked_sub(1).and_then(|i| styles.get(usize::try_from(i).ok()?)).filter(|g| g.id == *id)
|
||||
{
|
||||
let mut appearance = (*n.appearance).clone();
|
||||
if !n.is_container() && !matches!(n.kind, NodeKind::Text(_) | NodeKind::Image(_)) {
|
||||
appearance.contents_index = g.appearance.contents_index;
|
||||
}
|
||||
if g.transparency_matches(n) && g.appearance_on(n).approx_eq(&appearance) {
|
||||
let n = Arc::make_mut(n);
|
||||
n.appearance = appearance.into();
|
||||
n.extras_mut().graphic_style = Some(*id);
|
||||
}
|
||||
}
|
||||
if n.children().is_some_and(|c| !c.is_empty())
|
||||
&& let Some(c) = Arc::make_mut(n).children_mut()
|
||||
{
|
||||
link_styles(c, links, styles, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dictionary of entry `key` in `o` or the dictionaries it holds.
|
||||
/// How far (points) a ruler guide's ends may lie from the canvas's or an artboard's edges.
|
||||
const GUIDE_TOLERANCE: f64 = 0.01;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::containers::close;
|
||||
use super::document::slot_of;
|
||||
use super::types::{BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, line_width};
|
||||
use super::types::{BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, line_width};
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Gradient, GradientDef, Graph, HashMap, HashSet, Instance,
|
||||
Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, StrokeLayer, V, obj, paint,
|
||||
@@ -586,11 +586,17 @@ impl<'a> Reader<'a> {
|
||||
return;
|
||||
}
|
||||
// A style read here: the object, painted as it says.
|
||||
if let Some(appearance) = self
|
||||
if let Some(mut appearance) = self
|
||||
.styles
|
||||
.get(&style)
|
||||
.and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space }))
|
||||
{
|
||||
// Strokes and effects at the document's scale, as the art (a Large Canvas copy's is scaled up).
|
||||
let k = space.determinant().abs().sqrt();
|
||||
if k.is_finite() && k > 0.0 && (k - 1.0).abs() > 1e-9 {
|
||||
appearance.scale_strokes(k);
|
||||
appearance.scale_effects(k, vectorcraft_effects::scale_effect);
|
||||
}
|
||||
let Some(mut object) = f.children.pop() else { return };
|
||||
let Some(look) = f.children.pop() else { return };
|
||||
if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) {
|
||||
@@ -601,6 +607,10 @@ impl<'a> Reader<'a> {
|
||||
(object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout);
|
||||
// Now the object itself, not a copy an enclosing look's `strip` would remove.
|
||||
self.hidden_ids.remove(&object.id);
|
||||
// Linked to the style when the style list has its name ([`Reader::finish`]).
|
||||
if self.style_links.len() < MAX_NODES {
|
||||
self.style_links.push((object.id, style.clone()));
|
||||
}
|
||||
f.children.push(object);
|
||||
return;
|
||||
}
|
||||
@@ -752,21 +762,10 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
|
||||
AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf }))
|
||||
}
|
||||
},
|
||||
StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => {
|
||||
let mut layer = StrokeLayer::new(paint.clone(), line_width(*width));
|
||||
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
|
||||
AppearanceItem::Stroke(layer)
|
||||
}
|
||||
StyleItem::Stroke { .. } => stroke_layer(item)?,
|
||||
};
|
||||
if let Some(look) = st.looks.get(n) {
|
||||
match &mut layer {
|
||||
AppearanceItem::Fill(f) => {
|
||||
(f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone())
|
||||
}
|
||||
AppearanceItem::Stroke(s) => {
|
||||
(s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone())
|
||||
}
|
||||
}
|
||||
with_look(&mut layer, look);
|
||||
}
|
||||
items.push(layer);
|
||||
}
|
||||
@@ -778,6 +777,44 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
|
||||
Some(Appearance { items, effects: st.effects.clone(), contents_index })
|
||||
}
|
||||
|
||||
/// A graphic style's appearance from art style `st`, on no object: `None` when the style isn't one
|
||||
/// this reads, or paints with a gradient or a pattern (placed on each object as its drawn look has them).
|
||||
pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) -> Option<Appearance> {
|
||||
use crate::import::ai::style::StyleItem;
|
||||
if !st.plain {
|
||||
return None;
|
||||
}
|
||||
let mut items = vec![];
|
||||
for (n, item) in st.items.iter().enumerate() {
|
||||
let mut layer = match item {
|
||||
StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())),
|
||||
StyleItem::Stroke { .. } => stroke_layer(item)?,
|
||||
StyleItem::Gradient { .. } | StyleItem::Pattern { .. } => return None,
|
||||
};
|
||||
if let Some(look) = st.looks.get(n) {
|
||||
with_look(&mut layer, look);
|
||||
}
|
||||
items.push(layer);
|
||||
}
|
||||
Some(Appearance { items, effects: st.effects.clone(), contents_index: st.contents_index })
|
||||
}
|
||||
|
||||
/// A style's stroke as an appearance item (`None` for a fill).
|
||||
fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option<AppearanceItem> {
|
||||
let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } = item else { return None };
|
||||
let mut layer = StrokeLayer::new(paint.clone(), line_width(*width));
|
||||
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
|
||||
Some(AppearanceItem::Stroke(layer))
|
||||
}
|
||||
|
||||
/// Give `layer` a fill's or stroke's own visibility, transparency and effects.
|
||||
fn with_look(layer: &mut AppearanceItem, look: &crate::import::ai::style::ItemLook) {
|
||||
match layer {
|
||||
AppearanceItem::Fill(f) => (f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()),
|
||||
AppearanceItem::Stroke(s) => (s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look).
|
||||
fn strip(n: &mut Node, hidden: &HashSet<NodeId>) {
|
||||
if let Some(children) = n.children_mut() {
|
||||
|
||||
@@ -41,7 +41,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Dash, Effect, LineCap, LineJoin, WidthProfile};
|
||||
use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile};
|
||||
|
||||
use super::lex::{Lexer, Tok};
|
||||
use super::obj::V;
|
||||
@@ -114,6 +114,9 @@ pub(super) struct ArtStyle {
|
||||
pub effects: Vec<Effect>,
|
||||
/// How many of [`Self::items`] paint below the object's contents (`None`: none do).
|
||||
pub contents_index: Option<usize>,
|
||||
/// The object's own opacity, blend mode, isolation and knockout (its blend filter), when the
|
||||
/// style has them.
|
||||
pub transparency: Option<(f32, BlendMode, bool, Knockout)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -195,6 +198,8 @@ struct Filter {
|
||||
pattern: Option<(String, Option<[f64; 6]>)>,
|
||||
/// `mode opacity … Xy`.
|
||||
xy: Option<(usize, f32)>,
|
||||
/// `… isolate knockout … Xy`.
|
||||
xy_group: Option<(bool, Knockout)>,
|
||||
}
|
||||
|
||||
impl Default for Filter {
|
||||
@@ -219,6 +224,7 @@ impl Default for Filter {
|
||||
gradient_of: None,
|
||||
pattern: None,
|
||||
xy: None,
|
||||
xy_group: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +232,55 @@ impl Default for Filter {
|
||||
/// Most filters read in one style.
|
||||
const MAX_FILTERS: usize = 4096;
|
||||
|
||||
/// Most entries read from a style list.
|
||||
const MAX_LISTED: usize = 4096;
|
||||
/// The longest style name read from a style list, in bytes.
|
||||
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,
|
||||
/// a string the data ends inside and names past [`MAX_LISTED_NAME`] are left out.
|
||||
pub(super) fn art_style_list(data: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut names = vec![];
|
||||
let mut lex = Lexer::new(data);
|
||||
loop {
|
||||
let before = lex.offset();
|
||||
if names.len() >= MAX_LISTED {
|
||||
break;
|
||||
}
|
||||
let Some(t) = lex.next_token() else { break };
|
||||
// The lexer stops a string at its closing `)` or at the end of the data.
|
||||
let closed = lex.offset() < data.len() || data.get(before..).is_some_and(closes);
|
||||
if let Tok::Str(s) = t.tok
|
||||
&& closed
|
||||
&& s.len() <= MAX_LISTED_NAME
|
||||
{
|
||||
names.push(s);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Does the first literal string in `data` end with its own `)` (not an escaped one or one that
|
||||
/// closes a nested pair)?
|
||||
fn closes(data: &[u8]) -> bool {
|
||||
let Some(start) = data.iter().position(|b| *b == b'(') else { return false };
|
||||
let mut depth = 0usize;
|
||||
let mut bytes = data.iter().skip(start + 1);
|
||||
while let Some(b) = bytes.next() {
|
||||
match b {
|
||||
b'\\' => {
|
||||
bytes.next();
|
||||
}
|
||||
b'(' => depth += 1,
|
||||
b')' if depth == 0 => return true,
|
||||
b')' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The styles of the section `data` (between its markers), by name.
|
||||
pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
let mut styles = HashMap::new();
|
||||
@@ -426,11 +481,18 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
|
||||
}
|
||||
"Xy" => {
|
||||
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
|
||||
if let [mode, opacity, ..] = n.as_slice()
|
||||
if let [mode, opacity, rest @ ..] = n.as_slice()
|
||||
&& mode.is_finite()
|
||||
&& opacity.is_finite()
|
||||
{
|
||||
f.xy = Some((mode.max(0.0) as usize, opacity.clamp(0.0, 1.0) as f32));
|
||||
// As an object's own `Xy` reads them.
|
||||
let knockout = match rest.get(1).map(|v| *v as i64) {
|
||||
Some(1) => Knockout::On,
|
||||
Some(2) => Knockout::Neutral,
|
||||
_ => Knockout::Off,
|
||||
};
|
||||
f.xy_group = Some((rest.first().is_some_and(|v| *v != 0.0), knockout));
|
||||
}
|
||||
}
|
||||
// `flag (name) x y angle length a b c d tx ty Bg`.
|
||||
@@ -887,7 +949,12 @@ fn style(filters: &[Filter]) -> ArtStyle {
|
||||
}
|
||||
CONDUIT => {}
|
||||
// The object's own transparency, which its look carries too.
|
||||
BLEND => {}
|
||||
BLEND => {
|
||||
if let Some((mode, opacity)) = f.xy {
|
||||
let (isolate, knockout) = f.xy_group.unwrap_or((false, Knockout::Off));
|
||||
st.transparency = Some((opacity, super::read::blend_mode(mode), isolate, knockout));
|
||||
}
|
||||
}
|
||||
_ => match effect(f) {
|
||||
Some(e) => st.effects.push(e),
|
||||
None => b.plain = false,
|
||||
|
||||
@@ -275,6 +275,19 @@ fn art_styles_ignore_filters_beyond_the_cap() {
|
||||
assert!(style.effects.len() <= MAX_FILTERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_list_keeps_its_strings_in_order() {
|
||||
let list = art_style_list(b"(A)\n(B\\)C)\n(D(E))\n/Name 3 x\n(Caf\\303\\251)\n(F");
|
||||
let want: [&[u8]; 4] = [b"A", b"B)C", b"D(E)", "Caf\u{e9}".as_bytes()];
|
||||
assert_eq!(list, want, "the string the data ends inside is left out");
|
||||
assert!(art_style_list(b"").is_empty());
|
||||
let unended: [&[u8]; 1] = [b"A"];
|
||||
assert_eq!(art_style_list(b"(A)\n(B\\)"), unended, "an escaped `)` doesn't end the string");
|
||||
assert_eq!(art_style_list(b"(A)\n(B(C)"), unended, "nor does one that closes a nested pair");
|
||||
assert_eq!(art_style_list(format!("({})", "x".repeat(MAX_LISTED_NAME + 1)).as_bytes()), Vec::<Vec<u8>>::new());
|
||||
assert_eq!(art_style_list("(x)\n".repeat(MAX_LISTED + 5).as_bytes()).len(), MAX_LISTED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_dash_offsets_and_width_profiles_are_tamed() {
|
||||
let mut f = Filter::default();
|
||||
|
||||
@@ -2437,6 +2437,154 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// An art style named `name` (its bytes as written) that fills with colour `xa` (`c m y k r g b Xa`).
|
||||
fn fill_style(name: &str, xa: &str) -> String {
|
||||
fill_style_with(name, xa, "")
|
||||
}
|
||||
|
||||
/// [`fill_style`] with the filters `extra` after its fills.
|
||||
fn fill_style_with(name: &str, xa: &str, extra: &str) -> String {
|
||||
format!(
|
||||
"/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n{extra};\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// An art style named `name` with a drop shadow and no fills or strokes.
|
||||
fn shadow_style(name: &str) -> String {
|
||||
format!(
|
||||
"/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\
|
||||
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The art styles `defs` and the style list of `listed` (each written between parentheses as given).
|
||||
fn styles_and_list(defs: &[String], listed: &[&str]) -> String {
|
||||
let list: String = listed.iter().map(|n| format!("({n})\n")).collect();
|
||||
format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n{list}%AI9_EndArtStyleList\n", defs.concat())
|
||||
}
|
||||
|
||||
/// A red square with its drawn look, read as itself with the art style `name` (`1 (name) XW`).
|
||||
fn styled_square(name: &str) -> String {
|
||||
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 ({name}) XW\n", rect_path(0.0, 0.0, 10.0, 10.0))
|
||||
}
|
||||
|
||||
fn style_names(doc: &Document) -> Vec<(String, u32)> {
|
||||
doc.graphic_styles.iter().map(|g| (g.name.clone(), g.id)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_styles_replace_the_default_graphic_styles_and_link_their_objects() {
|
||||
let defs = [fill_style("Red", "0 0 0 0 1 0 0 Xa\n"), fill_style("Blue", "0 0 0 0 0 0 1 Xa\n"), shadow_style("Shadow")];
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["Red", "Blue", "Shadow"]), layer(&styled_square("Blue"))));
|
||||
assert_eq!(style_names(&r.doc), [("Red".to_string(), 1), ("Blue".to_string(), 2), ("Shadow".to_string(), 3)]);
|
||||
let blue = &r.doc.graphic_styles[1];
|
||||
assert_eq!(blue.appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let shadow = &r.doc.graphic_styles[2];
|
||||
assert_eq!(shadow.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
|
||||
assert_eq!(a[0].extras().graphic_style, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_listed_styles_that_are_defined_and_read_come_in_once() {
|
||||
// `Lit` paints with a gradient placed on each object; `Missing` has no definition.
|
||||
let defs = [fill_style("Blue", "0 0 0 0 0 0 1 Xa\n"), ART_STYLES.lines().filter(|l| !l.starts_with('%')).map(|l| format!("{l}\n")).collect()];
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["Blue", "Missing", "", "Lit", "Blue"]), layer("")));
|
||||
assert_eq!(style_names(&r.doc), [("Blue".to_string(), 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_the_list_does_not_have_is_no_link() {
|
||||
let defs = [fill_style("Blue", "0 0 0 0 0 0 1 Xa\n"), fill_style("Anon", "0 0 0 0 0 1 0 Xa\n")];
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["Blue"]), layer(&styled_square("Anon"))));
|
||||
assert_eq!(style_names(&r.doc), [("Blue".to_string(), 1)]);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 1.0, 0.0)), "the object still takes its appearance");
|
||||
assert_eq!(a[0].extras().graphic_style, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_with_a_listed_style_is_linked() {
|
||||
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
let group = format!("0 Ae\nu\n{RED}{}f\nU\n%_0 Ae\n%_u\n%_{hidden_rect}f\n%_U\n1 (Shadow) XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&[shadow_style("Shadow")], &["Shadow"]), layer(&group)));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
|
||||
assert_eq!(a[0].extras().graphic_style, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_whose_transparency_differs_from_its_listed_style_is_not_linked() {
|
||||
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
let square =
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n1 0.6 0 2 0 Xy\n6 () XW\n%_0 1 0 0 0 Xy\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]), layer(&square)));
|
||||
assert_eq!(style_names(&r.doc), [("Blue".to_string(), 1)]);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert_eq!(a[0].opacity, 0.6);
|
||||
assert_eq!(a[0].extras().graphic_style, None, "the style has full opacity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listed_style_keeps_its_transparency_and_links_objects_that_have_it() {
|
||||
let blend = "/BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
|
||||
/BlendStyle : 1 0.6 1 1 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n; /Dict ;\n /Part ,\n";
|
||||
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
let square =
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n1 0.6 1 1 0 Xy\n6 () XW\n%_0 1 0 0 0 Xy\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style_with("Blue", "0 0 0 0 0 0 1 Xa\n", blend)], &["Blue"]), layer(&square)));
|
||||
let g = &r.doc.graphic_styles[0];
|
||||
assert_eq!((g.name.as_str(), g.opacity, g.blend, g.isolate, g.knockout), ("Blue", 0.6, BlendMode::Multiply, true, Knockout::On));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert_eq!(a[0].extras().graphic_style, Some(1), "{a:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_with_a_listed_style_that_fills_below_the_contents_is_linked() {
|
||||
let below = "/KnownStyle :\n(Under) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||||
;\n /Part ,\n;\n";
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&[below.to_string()], &["Under"]), layer(&styled_square("Under"))));
|
||||
assert_eq!(r.doc.graphic_styles[0].appearance.contents_index, Some(1));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert_eq!(a[0].extras().graphic_style, Some(1), "{a:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_name_outside_ascii_links_its_objects() {
|
||||
// The list writes the name with escapes; the object's marker writes its UTF-8 bytes.
|
||||
let defs = [fill_style("Caf\u{e9} \u{3a9}", "0 0 0 0 0 0 1 Xa\n")];
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["Caf\\303\\251 \\316\\251"]), layer(&styled_square("Caf\u{e9} \u{3a9}"))));
|
||||
assert_eq!(style_names(&r.doc), [("Caf\u{e9} \u{3a9}".to_string(), 1)]);
|
||||
assert_eq!(art(&r.doc)[0].extras().graphic_style, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_style_list_the_default_graphic_styles_stay() {
|
||||
let defaults = style_names(&Document::new(1.0, 1.0));
|
||||
let r = read_ok(&format!("{ART_STYLES}{}", layer(&styled_square("Blue"))));
|
||||
assert_eq!(style_names(&r.doc), defaults);
|
||||
assert_eq!(art(&r.doc)[0].extras().graphic_style, None);
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&[shadow_style("Shadow")], &["Missing"]), layer("")));
|
||||
assert_eq!(style_names(&r.doc), defaults, "a list naming no style it can read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_with_a_feather_style_comes_in_with_a_live_effect() {
|
||||
let feather_style = "%AI9_BeginArtStyles\n\
|
||||
|
||||
@@ -97,6 +97,7 @@ pub(super) fn build_from(
|
||||
restyled,
|
||||
placed_elsewhere,
|
||||
envelopes_drawn,
|
||||
styles_read,
|
||||
warnings,
|
||||
} = structure;
|
||||
// Editing data in the units of the page's user space ([`in_units`]) is compared with the page
|
||||
@@ -108,7 +109,7 @@ pub(super) fn build_from(
|
||||
let units = |v: &Document| in_units(board, v, unit) || (!same_size(board, v) && in_units(art, v, unit));
|
||||
let small = visible.filter(|v| matches!(page, Page::Artboard) && units(v)).map(|v| {
|
||||
let mut v = v.clone();
|
||||
scale_document(&mut v, 1.0 / unit);
|
||||
scale_document(&mut v, 1.0 / unit, false);
|
||||
v
|
||||
});
|
||||
let visible = small.as_ref().or(visible);
|
||||
@@ -350,7 +351,7 @@ pub(super) fn build_from(
|
||||
}
|
||||
doc.limit_depth_noting(&mut notes);
|
||||
if small.is_some() {
|
||||
scale_document(&mut doc, unit);
|
||||
scale_document(&mut doc, unit, styles_read);
|
||||
relayout_wrapped(&mut doc);
|
||||
}
|
||||
Ok((doc, notes))
|
||||
@@ -1050,8 +1051,9 @@ fn drop_guides_off_boards(doc: &mut Document) {
|
||||
}
|
||||
|
||||
/// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their
|
||||
/// strokes, effects and pattern tiles.
|
||||
fn scale_document(doc: &mut Document, k: f64) {
|
||||
/// strokes, effects and pattern tiles, and with `styles` the strokes and effects of its graphic
|
||||
/// styles.
|
||||
fn scale_document(doc: &mut Document, k: f64, styles: bool) {
|
||||
let a = Affine::scale(k);
|
||||
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() };
|
||||
for layer in &mut doc.layers {
|
||||
@@ -1065,6 +1067,12 @@ fn scale_document(doc: &mut Document, k: f64) {
|
||||
for guide in &mut doc.guides {
|
||||
guide.pos *= k;
|
||||
}
|
||||
if styles {
|
||||
for g in doc.graphic_styles.iter_mut() {
|
||||
g.appearance.scale_strokes(k);
|
||||
g.appearance.scale_effects(k, vectorcraft_effects::scale_effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points
|
||||
|
||||
@@ -218,6 +218,8 @@ pub(super) fn fill_slots(
|
||||
let wrap = n.extras().wrap;
|
||||
// An art style read onto the slot (fills, strokes and effects round the characters) is the type's.
|
||||
let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone());
|
||||
// The graphic style it is linked to, which the type made from the text document keeps with it.
|
||||
let linked = n.extras().graphic_style;
|
||||
let replacement = match content.next() {
|
||||
Some(Content::Page(parts)) if !parts.is_empty() => {
|
||||
// The page's type (and the strokes round it) take the slot's place as they are.
|
||||
@@ -270,6 +272,15 @@ pub(super) fn fill_slots(
|
||||
Arc::make_mut(p).extras_mut().wrap = wrap;
|
||||
}
|
||||
}
|
||||
// One text object with the slot's appearance and transparency keeps its graphic style.
|
||||
if let ([p], Some(a)) = (parts.as_mut_slice(), styled.as_ref())
|
||||
&& linked.is_some()
|
||||
&& matches!(p.kind, NodeKind::Text(_))
|
||||
&& a.approx_eq(&p.appearance)
|
||||
&& (p.opacity, p.blend, p.isolate, p.knockout) == state
|
||||
{
|
||||
Arc::make_mut(p).extras_mut().graphic_style = linked;
|
||||
}
|
||||
let n = parts.len();
|
||||
nodes.splice(i..=i, parts);
|
||||
i += n;
|
||||
@@ -285,6 +296,9 @@ pub(super) fn fill_slots(
|
||||
let seen = seen || styled.is_some();
|
||||
if let Some(appearance) = styled {
|
||||
text.appearance = appearance;
|
||||
if linked.is_some() {
|
||||
text.extras_mut().graphic_style = linked;
|
||||
}
|
||||
}
|
||||
// Type the page shows is kept; other made type is weighed against the page.
|
||||
text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) };
|
||||
|
||||
@@ -1193,6 +1193,26 @@ fn editing_data_in_the_units_of_a_user_unit_page_comes_in_at_the_pages_size() {
|
||||
assert!(near(first(&d, 0), [100.0, 860.0, 140.0, 900.0]), "{:?}", first(&d, 0));
|
||||
}
|
||||
|
||||
/// The graphic styles of a `.ai` whose editing data is in the units of a `UserUnit` page come in
|
||||
/// with their stroke weights as large as the art's; the default styles stay as they are.
|
||||
#[test]
|
||||
fn graphic_styles_in_the_units_of_a_user_unit_page_come_in_at_the_pages_size() {
|
||||
let line = "/KnownStyle :\n(Line) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
|
||||
/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n";
|
||||
let styles = format!("%AI9_BeginArtStyles\n{line}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Line)\n%AI9_EndArtStyleList\n");
|
||||
let page = in_points(pdf_part(10.0, 86.0), 10.0);
|
||||
let art = native(&format!("{styles}{}", layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110))));
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), page.clone(), vec![], false, false, 10.0);
|
||||
assert!(read, "{w:?}");
|
||||
let widths: Vec<(&str, f64)> = d.graphic_styles.iter().map(|g| (g.name.as_str(), g.appearance.stroke_width())).collect();
|
||||
assert_eq!(widths, [("Line", 20.0)]);
|
||||
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110))));
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 10.0);
|
||||
assert!(read, "{w:?}");
|
||||
assert_eq!(d.graphic_styles, Document::new(1.0, 1.0).graphic_styles);
|
||||
}
|
||||
|
||||
/// #779: a `.ai`'s ruler guides come in as guides of their layers, at the page's size when the
|
||||
/// editing data is in the units of a `UserUnit` page.
|
||||
#[test]
|
||||
@@ -1447,6 +1467,46 @@ fn a_large_canvas_eps_keeps_its_layers_at_the_pages_size() {
|
||||
assert!(read && names(&d) == ["Art"], "{w:?}");
|
||||
}
|
||||
|
||||
/// The graphic styles of a Large Canvas editing copy come in at the page's size, as its art does, and
|
||||
/// an object drawn with one stays linked to it.
|
||||
#[test]
|
||||
fn a_large_canvas_eps_keeps_its_graphic_styles_at_the_pages_size() {
|
||||
let line = "/KnownStyle :\n(Line) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
|
||||
/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 0.2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n";
|
||||
let styles = format!("%AI9_BeginArtStyles\n{line}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Line)\n%AI9_EndArtStyleList\n");
|
||||
let square = "5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\n";
|
||||
let hidden: String = square.lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let styled = format!("0 Ae\nu\n0 0 1 0 K\n0.2 w\n{square}s\nU\n{hidden}%_n\n1 (Line) XW\n");
|
||||
let art = native_large_canvas(10.0, 10.0, &format!("{styles}{}", layer("Art", true, &styled)));
|
||||
let r = import(&eps(&page_square(50, 50), &art)).unwrap();
|
||||
let d = &r.document;
|
||||
assert_eq!(names(d), ["Art"], "{:?}", r.warnings);
|
||||
let widths: Vec<(&str, f64)> = d.graphic_styles.iter().map(|g| (g.name.as_str(), g.appearance.stroke_width())).collect();
|
||||
assert_eq!(widths, [("Line", 2.0)]);
|
||||
let object = &d.layers[0].children().unwrap()[0];
|
||||
assert_eq!((object.appearance.stroke_width(), object.extras().graphic_style), (2.0, Some(1)), "{object:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_a_listed_art_style_is_linked_to_its_graphic_style() {
|
||||
let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let drawing = "0 0 1 0 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf";
|
||||
let look = format!("u\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n");
|
||||
let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let list = "%AI9_BeginArtStyleList\n(Comic)\n%AI9_EndArtStyleList\n";
|
||||
let art = native_with_text(&(comic_style() + list + &layer("Art", true, &format!("{look}{own}1 (Comic) XW\n"))), &doc);
|
||||
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
|
||||
assert_eq!(d.graphic_styles.iter().map(|g| (g.name.as_str(), g.id)).collect::<Vec<_>>(), [("Comic", 1)]);
|
||||
let mut texts = vec![];
|
||||
d.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
texts.push(n.extras().graphic_style);
|
||||
}
|
||||
});
|
||||
assert_eq!(texts, [Some(1)], "{notes:?}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its
|
||||
/// pages still go where its editing data puts its artboards, with their art, and keep their names (#1068).
|
||||
#[test]
|
||||
|
||||
@@ -1072,6 +1072,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports.
|
||||
SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`).
|
||||
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning.
|
||||
`.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`).
|
||||
A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`).
|
||||
In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`).
|
||||
SVG export (live text), legacy `.ai` saves (text objects), DXF export (`TEXT` entities) and the CSS Properties panel write area type that Shrink Text to Fit scales down at the size it draws at, and scale its tracking, kerning, leading and baseline shifts the same way where the format holds them.
|
||||
|
||||
Reference in New Issue
Block a user