diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 675245b56..064ecbda1 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -12,6 +12,7 @@ mod object; mod para_style; mod runs; mod tabs_lists; +mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; @@ -22,6 +23,7 @@ pub use para_style::{ }; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; +pub use wrap::{resolve_wrap, wrap_work}; /// A text object. `runs` split into paragraphs at `\n`. /// diff --git a/crates/doc/src/text/wrap.rs b/crates/doc/src/text/wrap.rs new file mode 100644 index 000000000..0b5fe45dc --- /dev/null +++ b/crates/doc/src/text/wrap.rs @@ -0,0 +1,142 @@ +//! Object → Text Wrap: area type flows around the wrap objects stacked above it in the same layer. + +use std::sync::Arc; + +use vectorcraft_geom::{PathData, shapes}; + +use super::{TextKind, WrapShape}; +use crate::{Document, Node, NodeId, NodeKind}; + +/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art. +fn outline(n: &Node, out: &mut PathData) { + match &n.kind { + NodeKind::Path { path, guide: false, .. } => { + out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned()) + } + NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => { + for c in children.iter().filter(|c| c.visible) { + outline(c, out); + } + } + // A clip group wraps by its clipping path. + NodeKind::Group { children, clip: true } => { + if let Some(c) = children.first() { + outline(c, out); + } + } + _ => { + if let Some(b) = n.geometric_bounds() { + out.subpaths.extend(shapes::rectangle(b).subpaths); + } + } + } +} + +enum Item<'a> { + Wrap(&'a Node), + Text(&'a Node), +} + +fn collect<'a>(n: &'a Node, out: &mut Vec>) { + if !n.visible { + return; + } + if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { + out.push(Item::Wrap(n)); + return; + } + match &n.kind { + NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n)), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + for c in children { + collect(c, out); + } + } + _ => {} + } +} + +/// How much [`resolve_wrap`] would copy into the wrap shapes of every area text object in `doc`: +/// each wrap object's outline (its anchors, plus one for the shape) once for each area text object +/// below it in its layer. +pub fn wrap_work(doc: &Document) -> usize { + let mut total = 0usize; + for layer in &doc.layers { + let mut items = vec![]; + collect(layer, &mut items); + let mut above = 0usize; + for it in items.iter().rev() { + match it { + Item::Wrap(n) => { + let mut pd = PathData::default(); + outline(n, &mut pd); + if !pd.is_empty() { + let anchors: usize = pd.subpaths.iter().map(|sp| sp.anchors.len()).sum(); + above = above.saturating_add(anchors.saturating_add(1)); + } + } + Item::Text(_) => total = total.saturating_add(above), + } + } + } + total +} + +/// Resolve the wrap shapes (`TextObject::wrap`, text space) of each area text object from the +/// outlines of the wrap objects above it in its layer. With `before`, the layers it holds unchanged +/// are skipped; without it, every layer is resolved. Returns the area text objects whose shapes +/// changed, whose cached bounds are then out of date. +pub fn resolve_wrap(before: Option<&Document>, doc: &mut Document) -> Vec { + let mut updates: Vec<(NodeId, Vec)> = vec![]; + for (idx, layer) in doc.layers.iter().enumerate() { + if let Some(before) = before + && (before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer)) + || before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer))) + { + continue; + } + let mut items = vec![]; + collect(layer, &mut items); + if items.is_empty() { + continue; + } + // From the top down, each wrap object's outline is made once; each area text object takes + // those above it, bottom first. + let mut above: Vec = vec![]; + for it in items.iter().rev() { + match it { + Item::Wrap(n) => { + let mut pd = PathData::default(); + outline(n, &mut pd); + if !pd.is_empty() { + above.push(WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() }); + } + } + Item::Text(n) => { + let NodeKind::Text(t) = &n.kind else { continue }; + let inv = t.xf.inverse(); + let shapes: Vec = above + .iter() + .rev() + .map(|s| { + let mut path = s.path.clone(); + path.transform(inv); + WrapShape { path, wrap: s.wrap } + }) + .collect(); + if shapes != t.wrap { + updates.push((n.id, shapes)); + } + } + } + } + } + let mut changed = Vec::with_capacity(updates.len()); + for (id, shapes) in updates { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.wrap = shapes; + changed.push(id); + } + } + changed +} diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 2d641b176..8283a3ae3 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -1106,6 +1106,60 @@ fn texts(s: &Session) -> Vec { art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect() } +/// A `.ai` saved without its PDF part whose layer `Art` holds a story in two area type frames +/// (art x 10 to 90 and 110 to 190, y 10 to 90), then a band across the first frame (y 30 to 70), +/// made a wrap object with no offset when `wrap` is set. +fn wrapped_thread_ai(wrap: bool) -> Vec { + // The template box is centred at (100, 50), the canvas centre 8191.5: y runs down the canvas. + let canvas = |x: f64, y: f64| (x + 8091.5, 8241.5 - y); + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { + let corners = [canvas(x0, y1), canvas(x0, y0), canvas(x1, y0), canvas(x1, y1), canvas(x0, y1)]; + let segments: Vec = + corners.windows(2).map(|w| format!("{} {} {} {} {} {} {} {}", w[0].0, w[0].1, w[0].0, w[0].1, w[1].0, w[1].1, w[1].0, w[1].1)).collect(); + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", segments.join(" ")) + }; + let story = "Words keep flowing from the first frame into the second one, line after line, until the story ends here. ".repeat(3); + let n = story.chars().count() + 1; + let document = format!( + "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} {} ] >> >>\n\ + /1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \ + /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 10.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> << /0 1 >> ] >> >> ] /2 << /1 12.0 >> >>\n", + rect(10.0, 10.0, 90.0, 90.0), + rect(110.0, 10.0, 190.0, 90.0) + ); + let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + let frame = |f: u32| format!("/AI11Text :\n0 /FreeUndo ,\n{f} /FrameIndex ,\n0 /StoryIndex ,\n;\n"); + let dict = if wrap { "%_/ArtDictionary :\n%_0 /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n" } else { "" }; + let art = format!("{}{}0.5 g\n{}f\n{dict}", frame(0), frame(1), ai::rect(10.0, 30.0, 90.0, 70.0)); + let body = format!( + "%AI3_TemplateBox: 100 50 100 50\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n", + ai::layer("Art", &art), + lines.join("\n") + ); + ai::ai(&compressed(ai::editing_data(200.0, 100.0, &body).as_bytes()), PLACEHOLDER) +} + +/// Threaded area type below a wrap object flows around it when the file opens: the text the wrap +/// object pushes out of the first frame goes on in the second. +#[test] +fn wrap_threaded_area_after_open() { + let second = |wrap: bool| { + let mut s = Session::new(); + let r = open(&mut s, "thread.ai", &wrapped_thread_ai(wrap), json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.text_threads.len(), 1, "{r}"); + let text = |id: NodeId| match &doc.node(id).unwrap().kind { + NodeKind::Text(t) => t.clone(), + _ => panic!("area type"), + }; + let (first, second) = (text(doc.text_threads[0][0]), text(doc.text_threads[0][1])); + assert_eq!(first.wrap.len(), usize::from(wrap), "{r}"); + second.plain_text().chars().count() + }; + let (plain, wrapped) = (second(false), second(true)); + assert!(wrapped > plain, "the second frame holds more of the story: {plain} → {wrapped}"); +} + /// The note that glyphs were kept as outlines is dropped when the type comes from the editing data's /// text document, and stays with the PDF part alone. #[test] diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index b90a67105..caeb1f8fc 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -4,11 +4,8 @@ //! resolves, for each area text object, the outlines of the wrap objects above it into //! `TextObject::wrap` (text space); the layout engine subtracts them from each line's span. -use std::sync::Arc; - use serde_json::{Value, json}; -use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape}; -use vectorcraft_geom::{PathData, shapes}; +use vectorcraft_doc::{Document, NodeId, NodeKind, TextWrap}; use super::typecmd::refresh_bounds; use super::{CommandSpec, EngineError, Result, Session, bool_or, checked_ids_or_selection, cmd, f64_or, has_selection}; @@ -102,93 +99,10 @@ fn options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "offset": w.offset, "invert": w.invert })) } -/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art. -fn outline(n: &Node, out: &mut PathData) { - match &n.kind { - NodeKind::Path { path, guide: false, .. } => { - out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned()) - } - NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => { - for c in children.iter().filter(|c| c.visible) { - outline(c, out); - } - } - // A clip group wraps by its clipping path. - NodeKind::Group { children, clip: true } => { - if let Some(c) = children.first() { - outline(c, out); - } - } - _ => { - if let Some(b) = n.geometric_bounds() { - out.subpaths.extend(shapes::rectangle(b).subpaths); - } - } - } -} - -enum Item<'a> { - Wrap(&'a Node), - Text(NodeId), -} - -fn collect<'a>(n: &'a Node, out: &mut Vec>) { - if !n.visible { - return; - } - if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { - out.push(Item::Wrap(n)); - return; - } - match &n.kind { - NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n.id)), - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { - for c in children { - collect(c, out); - } - } - _ => {} - } -} - /// Re-resolve the wrap shapes of every area text object (called after each edit). pub(crate) fn refresh(before: &Document, doc: &mut Document) { - let mut updates: Vec<(NodeId, Vec)> = vec![]; - for (idx, layer) in doc.layers.iter().enumerate() { - if before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer)) || before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer)) { - continue; - } - let mut items = vec![]; - collect(layer, &mut items); - if items.is_empty() { - continue; - } - for (i, it) in items.iter().enumerate() { - let Item::Text(id) = it else { continue }; - let Some(NodeKind::Text(t)) = doc.node(*id).map(|n| &n.kind) else { continue }; - let inv = t.xf.inverse(); - let shapes: Vec = items[i + 1..] - .iter() - .filter_map(|w| match w { - Item::Wrap(n) => { - let mut pd = PathData::default(); - outline(n, &mut pd); - (!pd.is_empty()).then(|| { - pd.transform(inv); - WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() } - }) - } - Item::Text(_) => None, - }) - .collect(); - if shapes != t.wrap { - updates.push((*id, shapes)); - } - } - } - for (id, shapes) in updates { + for id in vectorcraft_doc::text::resolve_wrap(Some(before), doc) { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { - t.wrap = shapes; refresh_bounds(t); } } diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 37fb0d3f8..5177b3ea7 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3548,6 +3548,37 @@ fn arb_ai_token() -> impl Strategy { ] } +/// Text wrap in editing data: an object's dictionary with the wrap options in any order, missing, +/// repeated or of the wrong type, with numbers that aren't finite, nested dictionaries and the kept +/// outline's art; and wraparound groups (`*w` … `*W`), unmatched or nested. +fn arb_ai_wrap_entry() -> impl Strategy { + let value = prop_oneof![ + arb_num(), + Just("nan".to_string()), + Just("inf".to_string()), + Just("-1e999".to_string()), + Just("1e-999".to_string()), + Just("(far)".to_string()), + Just("/Name".to_string()), + Just("[ 1 2 ]".to_string()), + ]; + let ty = prop::sample::select(vec!["/Real", "/Bool", "/Int", "/String", ""]); + let key = prop::sample::select(vec!["AI11TextWrapOffset", "AI11TextWrapInvert", "AI11TextWrapPath", "AI11TextWrapBeingComputed"]); + let entry = (value, ty, key).prop_map(|(v, t, k)| format!("%_{v} {t} ({k}) ,")); + let art = prop::sample::select(vec![ + "%_X=\n%_0 0 m\n%_0 9 L\n%_9 9 L\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,", + "%_X=\n%_X=\n%_0 0 m\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,", + "%_X+\n%_ (AI11TextWrapPath) ,", + "%_/Dictionary :\n%_6 /Real (AI11TextWrapOffset) ,\n%_; (Nested) ,", + "%_/Dictionary :\n%_1 /Bool (AI11TextWrapInvert) ,", + ]) + .prop_map(str::to_string); + let dictionary = prop::collection::vec(prop_oneof![3 => entry, 1 => art], 0..6) + .prop_map(|entries| format!("\n%_/ArtDictionary :\n{}\n%_;\n%_\n", entries.join("\n"))); + let group = prop::sample::select(vec!["*w", "*W", "*w *w", "*W *W", "*w u", "U *W", "*w 1 To", "TO *W"]).prop_map(str::to_string); + prop_oneof![2 => dictionary, 1 => group] +} + /// The testkit's sample editing data, compressed as older `.ai` files compress it. fn ai_sample_compressed() -> Vec { [&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat() @@ -3611,7 +3642,7 @@ proptest! { /// Hostile editing data in Illustrator EPS and `.ai` files: operators in any order with any /// operands, sections and dictionaries left open, images of any size. #[test] - fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) { + fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(prop_oneof![8 => arb_ai_token(), 1 => arb_ai_wrap_entry()], 0..80)) { use vectorcraft_testkit::ai; let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" "))); survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index a894b425b..e652d3755 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -2,6 +2,7 @@ use super::document::slot_of; use super::ops::{paints_apart, path_rule}; use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES, PendingEnvelope}; use super::{AppearanceItem, Arc, BezPath, BlendMode, FillRule, HashSet, LayerColor, Node, NodeId, NodeKind, Obj, Paint, PathEl, Rc, Reader, V, obj}; +use vectorcraft_doc::TextWrap; impl<'a> Reader<'a> { // ---- frames ---------------------------------------------------------------------------- @@ -77,11 +78,12 @@ impl<'a> Reader<'a> { let f = match what { "U" => self.close(what, |k| matches!(k, Kind::Group))?, "*U" => self.close(what, |k| matches!(k, Kind::Compound))?, + "*W" => self.close(what, |k| matches!(k, Kind::Wrap))?, _ => self.close(what, |k| matches!(k, Kind::Clip))?, }; let Frame { kind, - children: kids, + children: mut kids, gs, clips, hidden: opened_hidden, @@ -96,6 +98,15 @@ impl<'a> Reader<'a> { self.pending_unread_fill = pending_unread_fill; self.pending_unread_stroke = pending_unread_stroke; } + // Wraparound text: the objects after its text object are wrap objects, at the default + // options (the format gives none) unless their dictionaries give theirs. + if matches!(kind, Kind::Wrap) + && let Some(text) = kids.iter().position(|k| matches!(k.kind, NodeKind::Text(_)) || slot_of(k.name.as_deref()).is_some()) + { + for k in kids.iter_mut().skip(text + 1).filter(|k| k.extras().wrap.is_none()) { + k.extras_mut().wrap = Some(TextWrap::default()); + } + } // A group holding only clipping paths (`q … u W n U … Q`) doesn't clip on its own: `u`/`U` don't save // the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it. if matches!(kind, Kind::Group) @@ -423,7 +434,7 @@ impl<'a> Reader<'a> { /// Mark the first object container below the innermost layer. pub(super) fn mark_unread(&mut self, what: String) -> bool { let Some(layer) = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))) else { return false }; - let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)) else { + let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Wrap | Kind::Compound | Kind::Clip)) else { return false; }; if !frame.unread.contains(&what) { @@ -443,11 +454,9 @@ impl<'a> Reader<'a> { self.unreadable(what, false); return Ok(None); } - let in_container = self - .frames - .iter() - .rposition(|f| matches!(f.kind, Kind::Layer(_))) - .is_some_and(|layer| self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip))); + let in_container = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))).is_some_and(|layer| { + self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Wrap | Kind::Compound | Kind::Clip)) + }); if !super::art::sane(bounds) || bounds.area() <= 0.0 { self.unsupported.insert(what.into()); return Ok(None); diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 0874ec095..ef41a5f1c 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -5,7 +5,7 @@ use super::{ Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj, }; -use vectorcraft_doc::{Guide, ImageBlob, PatternDef}; +use vectorcraft_doc::{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 { @@ -220,6 +220,23 @@ fn variable_sets_node(data: &[u8]) -> Option<&[u8]> { } } +/// The text wrap options an object's dictionary gives: its offset in points (`AI11TextWrapOffset`, +/// times `scale`, the factor that takes the art onto the document) and Invert Wrap +/// (`AI11TextWrapInvert`). The outline the file may keep with them (`AI11TextWrapPath`) is not +/// read: the object's own shape gives it. A value that isn't a number is left at its default, and +/// a dictionary without a readable entry gives none. +fn text_wrap(o: &Obj, scale: f64) -> Option { + let num = |key: &str| o.get(key).and_then(|v| v.iter().find_map(V::num)).filter(|v| v.is_finite()); + let offset = num("AI11TextWrapOffset"); + let invert = num("AI11TextWrapInvert").filter(|v| *v == 0.0 || *v == 1.0); + if offset.is_none() && invert.is_none() && o.get("AI11TextWrapPath").is_none() { + return None; + } + let default = TextWrap::default(); + let offset = offset.map(|v| v * scale).filter(|v| !v.is_nan()).map_or(default.offset, |v| v.clamp(-1000.0, 1000.0)); + Some(TextWrap { offset, invert: invert == Some(1.0) }) +} + impl<'a> Reader<'a> { /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the /// opacity mask it may carry. @@ -263,6 +280,12 @@ impl<'a> Reader<'a> { self.unreadable("opacity masks without readable art", false); } } + // Text wrap: area type below the object in its layer flows around it. + if let Some(wrap) = text_wrap(o, self.large_canvas.unwrap_or(1.0)) + && let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) + { + n.extras_mut().wrap = Some(wrap); + } let name = match o.text("AIArtName") { Some(name) => name, None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 6e12e89b1..7f097df4a 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -125,11 +125,15 @@ impl<'a> Reader<'a> { self.end_path_quietly(); self.open(Kind::Compound, hidden)? } + "*w" => { + self.end_path_quietly(); + self.open(Kind::Wrap, hidden)? + } "q" => { self.end_path_quietly(); self.open(Kind::Clip, hidden)? } - "U" | "*U" | "Q" => self.end_container(w, hidden)?, + "U" | "*U" | "*W" | "Q" => self.end_container(w, hidden)?, "Lb" => self.layer_attrs(&vals), "Ln" => { if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() { diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 47976a3b1..1ce723653 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -142,6 +142,9 @@ pub(super) struct LayerAttrs { pub(super) enum Kind { Layer(Box), Group, + /// Wraparound text (`*w` … `*W`, version 7 specification §15.4.2): a group whose objects after + /// its text object are wrap objects. + Wrap, Compound, Clip, Mask, diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 7a1ce88e7..37dfcac4f 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3247,6 +3247,102 @@ fn a_blend_record_is_read_to_its_length() { assert!(matches!(blend.kind, NodeKind::Blend { .. }), "{warnings:?}"); } +/// An object's dictionary with the text wrap `entries` (each a `values (key) ,` line). +fn wrap_dictionary(entries: &[&str]) -> String { + let lines: String = entries.iter().map(|e| format!("%_{e}\n")).collect(); + format!("%_/ArtDictionary :\n{lines}%_;\n%_\n") +} + +/// The wrap outline a file may keep beside the options: art, then its key. +const WRAP_PATH: &str = "X=\n%_4 As\n%_0 0 m\n%_0 20 L\n%_20 20 L\n%_20 0 L\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,"; + +fn wrap_of(n: &Node) -> Option { + n.extras().wrap +} + +#[test] +fn wrap_dictionary_path() { + let dict = wrap_dictionary(&[WRAP_PATH, "0 /Bool (AI11TextWrapInvert) ,", "12.5 /Real (AI11TextWrapOffset) ,"]); + let r = read_ok(&layer(&format!("{RED}{}f\n{dict}", rect_path(10.0, 10.0, 50.0, 50.0)))); + let kids = art(&r.doc); + assert_eq!(kids.len(), 1, "the kept outline isn't art: {:?}", r.warnings); + assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: false })); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +#[test] +fn wrap_dictionary_invert_and_default() { + let inverted = wrap_dictionary(&["12.5 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]); + let default = wrap_dictionary(&["0 /Bool (AI11TextWrapInvert) ,"]); + let plain = wrap_dictionary(&["(Plain) /String (AIArtName) ,"]); + let r = read_ok(&layer(&format!( + "{RED}{}f\n{inverted}{}f\n{default}{}f\n{plain}", + rect_path(0.0, 0.0, 10.0, 10.0), + rect_path(20.0, 0.0, 30.0, 10.0), + rect_path(40.0, 0.0, 50.0, 10.0) + ))); + let kids = art(&r.doc); + assert_eq!(kids.len(), 3); + assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: true })); + assert_eq!(wrap_of(&kids[1]), Some(vectorcraft_doc::TextWrap::default()), "6 pt when the offset isn't given"); + assert_eq!(wrap_of(&kids[2]), None, "an object without wrap options isn't a wrap object"); + assert_eq!(kids[2].name.as_deref(), Some("Plain")); +} + +#[test] +fn wrap_dictionary_bad_offset() { + let offsets = ["nan /Real", "1e308 /Real", "(far) /String", "2000 /Real", "-2000 /Real"]; + let mut body = RED.to_string(); + for (i, offset) in offsets.iter().enumerate() { + let x = 20.0 * i as f64; + body += &format!("{}f\n", rect_path(x, 0.0, x + 10.0, 10.0)); + body += &wrap_dictionary(&[&format!("{offset} (AI11TextWrapOffset) ,"), "1 /Bool (AI11TextWrapInvert) ,"]); + } + // An offset that isn't a number, with nothing else of the options, isn't a wrap object. + body += &format!("{}f\n", rect_path(200.0, 0.0, 210.0, 10.0)); + body += &wrap_dictionary(&["(far) /String (AI11TextWrapOffset) ,", "7 /Bool (AI11TextWrapInvert) ,"]); + let r = read_ok(&layer(&body)); + let offsets: Vec> = art(&r.doc).iter().map(|n| wrap_of(n).map(|w| w.offset)).collect(); + assert_eq!(offsets, [Some(6.0), Some(1000.0), Some(6.0), Some(1000.0), Some(-1000.0), None]); + assert!(art(&r.doc).iter().take(5).all(|n| wrap_of(n).is_some_and(|w| w.invert))); +} + +#[test] +fn wrap_dictionary_group() { + let dict = wrap_dictionary(&["6 /Real (AI11TextWrapOffset) ,", "0 /Bool (AI11TextWrapInvert) ,"]); + let r = read_ok(&layer(&format!("u\n{RED}{}f\n{}f\nU\n{dict}", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0)))); + let kids = art(&r.doc); + assert_eq!(kids.len(), 1); + assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap::default())); + let NodeKind::Group { children, .. } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) }; + assert_eq!(children.len(), 2); + assert!(children.iter().all(|c| wrap_of(c).is_none() && matches!(c.kind, NodeKind::Path { .. }))); +} + +/// Wraparound text in the version 7 format (§15.4.2): `*w`, objects, the area text, the objects it +/// wraps around, `*W`. +#[test] +fn legacy_wrap_group() { + let text = format!("1 To\n1 0 0 1 0 0 0 Tp\n{}n\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Around) Tx\nTO\n", rect_path(0.0, 0.0, 100.0, 80.0)); + // The last object's own dictionary gives its options. + let own = wrap_dictionary(&["3 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]); + let body = format!( + "*w\n{RED}{}f\n{text}{}f\n{}f\n{own}*W\n", + rect_path(0.0, 0.0, 5.0, 5.0), + rect_path(30.0, 30.0, 50.0, 50.0), + rect_path(60.0, 30.0, 70.0, 50.0) + ); + let r = read_ok(&layer(&body)); + let kids = art(&r.doc); + assert_eq!(kids.len(), 1, "{:?}", r.warnings); + let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) }; + let wraps: Vec<(bool, bool)> = children.iter().map(|c| (matches!(c.kind, NodeKind::Text(_)), wrap_of(c).is_some())).collect(); + assert_eq!(wraps, [(false, false), (true, false), (false, true), (false, true)], "{:?}", r.warnings); + assert_eq!(wrap_of(&children[2]), Some(vectorcraft_doc::TextWrap::default())); + assert_eq!(wrap_of(&children[3]), Some(vectorcraft_doc::TextWrap { offset: 3.0, invert: true })); + assert!(!r.warnings.iter().any(|w| w.contains("*w") || w.contains("*W")), "{:?}", r.warnings); +} + /// The printer's crop marks Illustrator draws after the last layer (`%AI3_BeginCrops`, inside /// `userdict /AI3_noCropMarks known not { … } if`) aren't art outside the layers: the file reads /// from its layers, without them (the crop area is `%AI3_Cropmarks`). diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 819e8c674..0b632f558 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -289,6 +289,8 @@ pub(super) fn build_from( doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect()); } } + // Area type flows around the wrap objects above it before it is compared with the page. + resolve_text_wrap(&mut doc)?; if empty > 0 { notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); } @@ -349,10 +351,47 @@ pub(super) fn build_from( doc.limit_depth_noting(&mut notes); if small.is_some() { scale_document(&mut doc, unit); + relayout_wrapped(&mut doc); } Ok((doc, notes)) } +/// Most anchors copied into the wrap shapes of a file's area type ([`vectorcraft_doc::text::wrap_work`]). +const MAX_WRAP_WORK: usize = 2_000_000; + +/// [`relayout_wrapped`], or an error when the wrap shapes would hold more than [`MAX_WRAP_WORK`] +/// anchors. +fn resolve_text_wrap(doc: &mut Document) -> Result<(), String> { + if has_wraps(doc) && vectorcraft_doc::text::wrap_work(doc) > MAX_WRAP_WORK { + return Err("it has too many text objects".into()); + } + relayout_wrapped(doc); + Ok(()) +} + +/// Does an object of `doc` have text wrap options, or a text object wrap shapes? +fn has_wraps(doc: &Document) -> bool { + let mut wraps = false; + doc.walk(|n| wraps |= n.extras().wrap.is_some() || matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty())); + wraps +} + +/// Resolve the wrap shapes of the area type in `doc` that has wrap objects above it (see +/// [`vectorcraft_doc::text::resolve_wrap`]); the type whose shapes changed is laid out again. +/// Nothing is resolved when the shapes would hold more than [`MAX_WRAP_WORK`] anchors (such a +/// file isn't read from its layers: see [`resolve_text_wrap`]). +fn relayout_wrapped(doc: &mut Document) { + if !has_wraps(doc) || vectorcraft_doc::text::wrap_work(doc) > MAX_WRAP_WORK { + return; + } + for id in vectorcraft_doc::text::resolve_wrap(None, doc) { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.cached_bounds = None; + t.cached_baselines.clear(); + } + } +} + /// A visible page node, with its source order and subtree range. struct PageArt { node: Arc, @@ -836,6 +875,10 @@ fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option { moved = true; } } + // Area type wrapped around a moved image flows around it where it now is. + if moved { + relayout_wrapped(&mut doc); + } moved.then_some(doc) } @@ -1012,7 +1055,9 @@ fn scale_document(doc: &mut Document, k: f64) { 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 { - Arc::make_mut(layer).transform(a, scaling); + let layer = Arc::make_mut(layer); + layer.transform(a, scaling); + scale_wrap_offsets(layer, k); } for board in &mut doc.artboards { board.rect = a.transform_rect_bbox(board.rect); @@ -1022,6 +1067,22 @@ fn scale_document(doc: &mut Document, k: f64) { } } +/// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points +/// they may be). +fn scale_wrap_offsets(n: &mut Node, k: f64) { + if let Some(w) = n.extras_if_any().and_then(|e| e.wrap.as_mut()) { + let v = w.offset * k; + if !v.is_nan() { + w.offset = v.clamp(-1000.0, 1000.0); + } + } + if let Some(children) = n.children_mut() { + for c in children { + scale_wrap_offsets(Arc::make_mut(c), k); + } + } +} + /// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's /// text document. pub(super) const MADE: &str = "\u{1}made"; @@ -1102,6 +1163,8 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc let mut with = view.colour_difference(doc, rect); let mut removed = 0; for (id, bounds) in found { + // A removal before it can have let its area type flow differently. + let bounds = if removed > 0 { doc.node(id).and_then(|n| n.visual_bounds()) } else { bounds }; if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) { continue; } @@ -1109,9 +1172,12 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc if !take_out(&mut without.layers, id, true) { continue; } + // Area type that wrapped around the text object flows without it. + relayout_wrapped(&mut without); let d = view.colour_difference(&without, rect); if d - with <= UNSEEN_MARGIN { take_out(&mut doc.layers, id, false); + relayout_wrapped(doc); with = d; removed += 1; } @@ -1125,6 +1191,115 @@ mod candidate_tests { use vectorcraft_doc::{ImageBlob, ImageObject}; + /// Layers read in the units of a page scaled up to points take their wrap offsets with their art, + /// within ±1000 points. + #[test] + fn scaling_the_document_scales_its_wrap_offsets() { + let mut doc = Document::new(100.0, 100.0); + let mut ids = vec![]; + for (x, offset) in [(0.0, 6.0), (20.0, 200.0), (40.0, -150.0)] { + let id = doc.alloc_id(); + let mut n = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Default::default()); + n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset, invert: true }); + doc.insert(Some(doc.layers[0].id), 0, n).unwrap(); + ids.push(id); + } + scale_document(&mut doc, 10.0); + let offsets: Vec<_> = ids.iter().map(|id| doc.node(*id).and_then(|n| n.extras().wrap)).collect(); + let wrap = |offset| Some(vectorcraft_doc::TextWrap { offset, invert: true }); + assert_eq!(offsets, [wrap(60.0), wrap(1000.0), wrap(-1000.0)]); + } + + /// Area type in `doc`'s first layer at `index`, in the frame `r`. + fn insert_area(doc: &mut Document, index: usize, r: Rect) -> NodeId { + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped", Default::default()); + t.kind = vectorcraft_doc::TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(r) }; + let id = doc.alloc_id(); + doc.insert(Some(doc.layers[0].id), index, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap() + } + + /// A wrap object on top of `doc`'s first layer: a closed path of `anchors` anchors in `r`. + fn insert_wrap(doc: &mut Document, r: Rect, anchors: usize) -> NodeId { + let mut bp = kurbo::BezPath::new(); + bp.move_to((r.x0, r.y0)); + for i in 1..anchors { + let t = i as f64 / anchors as f64; + bp.line_to((r.x0 + r.width() * t, if i % 2 == 0 { r.y0 } else { r.y1 })); + } + bp.close_path(); + let id = doc.alloc_id(); + let mut n = Node::path(id, PathData::from_bezpath(&bp), Default::default()); + n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default()); + let top = doc.layers[0].children().map_or(0, Vec::len); + doc.insert(Some(doc.layers[0].id), top, n).unwrap() + } + + fn wrap_shapes(doc: &Document, id: NodeId) -> usize { + match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.wrap.len(), + _ => panic!("area type"), + } + } + + /// A file whose wrap shapes would copy more than `MAX_WRAP_WORK` anchors isn't read from its + /// layers; one below that is. + #[test] + fn wrap_shapes_of_too_many_text_objects_are_refused() { + for (texts, ok) in [(150, true), (250, false)] { + let mut doc = Document::new(100.0, 100.0); + let ids: Vec = (0..texts).map(|_| insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0))).collect(); + insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 10_000); + assert!(vectorcraft_doc::text::wrap_work(&doc) >= texts * 10_000); + assert_eq!(resolve_text_wrap(&mut doc).is_ok(), ok, "{texts} text objects"); + // Nor is anything copied on the way, as the placed files' other placement does. + relayout_wrapped(&mut doc); + assert!(ids.iter().all(|id| wrap_shapes(&doc, *id) == usize::from(ok)), "{texts} text objects"); + } + } + + /// Area type that wrapped around an object taken out of the layers flows without it. + #[test] + fn wrap_shapes_go_with_their_wrap_object() { + let mut doc = Document::new(100.0, 100.0); + let text = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0)); + let wrap = insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 4); + resolve_text_wrap(&mut doc).unwrap(); + assert_eq!(wrap_shapes(&doc, text), 1); + doc.remove(wrap).unwrap(); + relayout_wrapped(&mut doc); + assert_eq!(wrap_shapes(&doc, text), 0); + } + + /// Area type wrapped around a placed image flows around it at the image's other placement. + #[test] + fn wrap_shapes_follow_an_image_to_its_other_placement() { + let mut doc = Document::new(100.0, 100.0); + let text = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0)); + let image = doc.alloc_id(); + let mut n = Node::new( + image, + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: Affine::translate(Vec2::new(10.0, 10.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default()); + doc.insert(Some(doc.layers[0].id), 1, n).unwrap(); + resolve_text_wrap(&mut doc).unwrap(); + let x0 = |d: &Document| match d.node(text).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.wrap.first().and_then(|w| w.path.bounds()).map(|b| b.x0), + _ => None, + }; + assert_eq!(x0(&doc), Some(10.0)); + let moved = elsewhere(&doc, &[(image, Affine::translate(Vec2::new(50.0, 0.0)))]).unwrap(); + assert_eq!(x0(&moved), Some(60.0)); + } + /// A page image with a different proxy keeps its editing layer and original blob. #[test] fn page_image_with_different_proxy_keeps_editing_layer() { diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index 188a1b6e6..e44efa230 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -214,6 +214,8 @@ pub(super) fn fill_slots( let frame = ai::slot_frame(n.name.as_deref()); let state = (n.opacity, n.blend, n.isolate, n.knockout); let transparent = see_through(n); + // A text object that is a wrap object stays one. + 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()); let replacement = match content.next() { @@ -257,6 +259,17 @@ pub(super) fn fill_slots( parts.push(Arc::new(group)); } } + // A wrap object stays one: the page's parts of it in a group, when there are several. + if wrap.is_some() { + if parts.len() > 1 { + let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts)); + group.visible = visible; + parts.push(Arc::new(group)); + } + for p in &mut parts { + Arc::make_mut(p).extras_mut().wrap = wrap; + } + } let n = parts.len(); nodes.splice(i..=i, parts); i += n; @@ -277,6 +290,9 @@ pub(super) fn fill_slots( text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) }; // The type takes the slot's transparency. (text.opacity, text.blend, text.isolate, text.knockout) = state; + if wrap.is_some() { + text.extras_mut().wrap = wrap; + } if let Some(slot) = nodes.get_mut(i) { *slot = Arc::new(*text); } @@ -477,3 +493,33 @@ pub(super) fn covered(object: &[Arc], content: &[Content], keep: impl Fn(u pub(super) fn extent(parts: &[Arc]) -> Option { parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A text slot that is a wrap object, filled with the page's type in two parts, stays one + /// wrap object: a group of the parts. + #[test] + fn a_wrapped_slot_filled_from_the_page_stays_one_wrap_object() { + let mut doc = Document::new(100.0, 100.0); + let mut slot = Node::group(doc.alloc_id(), vec![]); + slot.name = Some("\u{0}text0:0".into()); + let wrap = vectorcraft_doc::TextWrap { offset: 3.0, invert: true }; + slot.extras_mut().wrap = Some(wrap); + let part = |doc: &mut Document, x: f64| { + let t = vectorcraft_doc::TextObject::point(Point::new(x, 50.0), "Part", Default::default()); + Arc::new(Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)))) + }; + let parts = vec![part(&mut doc, 10.0), part(&mut doc, 60.0)]; + let mut nodes = vec![Arc::new(slot)]; + let mut content = vec![Content::Page(parts)].into_iter(); + let (mut empty, mut made) = (0, vec![]); + fill_slots(&mut doc, &mut nodes, &mut content, &Default::default(), &mut empty, &mut made, &mut vec![]); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].extras().wrap, Some(wrap)); + let NodeKind::Group { children, .. } = &nodes[0].kind else { panic!("a group: {:?}", nodes[0].kind) }; + assert_eq!(children.len(), 2); + assert!(children.iter().all(|c| c.extras().wrap.is_none() && matches!(c.kind, NodeKind::Text(_)))); + } +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 15b3e21ca..02b7a613a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -2801,6 +2801,109 @@ fn threaded_area_type_flows_through_its_frames() { assert_eq!(r.document.text_threads, [ids]); } +/// A filled bar of the art from x 40 to 60, the whole page high, made a wrap object with an offset +/// of 2 points when `wrap` is set. +fn wrap_bar(wrap: bool) -> String { + let dict = "%_/ArtDictionary :\n%_0 /Bool (AI11TextWrapInvert) ,\n%_2 /Real (AI11TextWrapOffset) ,\n%_;\n%_\n"; + format!("0 0 1 0 k\n40 0 m\n40 100 L\n60 100 L\n60 0 L\nf\n{}", if wrap { dict } else { "" }) +} + +/// A story long enough to fill several lines of a 10-point area type frame. +const WRAP_STORY: &str = "Text flows around the bar in the middle of its frame when the bar is a wrap object\\r"; + +/// The `.ai` editing data of area type from x 10 to 90 of the art, with `below` before it and +/// `above` after it in its layer, and `other` on a layer above → the document read from it alone. +fn wrapped_area(below: &str, above: &str, other: Option<&str>) -> Document { + let doc = document_of(&[area_frame(10.0, 10.0, 90.0, 90.0)], &[story(WRAP_STORY, &[0], "/0 0", "/1 10.0")]); + let mut layers = layer("Art", true, &format!("{below}\n{}{above}", frame_object(0, 0))); + if let Some(other) = other { + layers += &layer("Top", true, other); + } + let (d, notes) = crate::ai_alone(&private(&native_with_text(&layers, &doc))).unwrap(); + assert!(!notes.iter().any(|n| n.contains("couldn't be read")), "{notes:?}"); + d +} + +/// The area type of `d` (its first text object). +fn area_text(d: &Document) -> vectorcraft_doc::TextObject { + let mut found = None; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind + && found.is_none() + { + found = Some((**t).clone()); + } + }); + found.expect("area type") +} + +#[test] +fn wrap_area_after_open() { + let d = wrapped_area("", &wrap_bar(true), None); + let t = area_text(&d); + assert_eq!(t.wrap.len(), 1, "one wrap shape, the bar's"); + assert_eq!(t.wrap[0].wrap, vectorcraft_doc::TextWrap { offset: 2.0, invert: false }); + // Each line keeps to one side of the bar and its 2-point offset. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.lines.len() > 1); + let spans: Vec<(f64, f64)> = lay.lines.iter().map(|l| l.avail).collect(); + assert!(spans.iter().all(|(a, b)| *b <= 38.0 + 1e-6 || *a >= 62.0 - 1e-6), "{spans:?}"); + assert!(spans.iter().any(|(_, b)| *b <= 38.0 + 1e-6) && spans.iter().any(|(a, _)| *a >= 62.0 - 1e-6), "{spans:?}"); + // A wrap object below the type, or on another layer, doesn't wrap it. + assert!(area_text(&wrapped_area(&wrap_bar(true), "", None)).wrap.is_empty()); + assert!(area_text(&wrapped_area("", "", Some(&wrap_bar(true)))).wrap.is_empty()); +} + +#[test] +fn no_wrap_metadata_unchanged() { + let d = wrapped_area("", &wrap_bar(false), None); + let t = area_text(&d); + assert!(t.wrap.is_empty()); + let mut wraps = 0; + d.walk(|n| wraps += usize::from(n.extras().wrap.is_some())); + assert_eq!(wraps, 0); + // The lines span the frame, through the bar. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!( + lay.lines.iter().all(|l| l.avail.0 <= 10.0 + 1e-6 && l.avail.1 >= 90.0 - 1e-6), + "{:?}", + lay.lines.iter().map(|l| l.avail).collect::>() + ); +} + +/// A text object that is itself a wrap object keeps its options when its type is made from the +/// text document. +#[test] +fn a_wrapped_text_object_stays_a_wrap_object() { + let doc = document_of( + &[area_frame(10.0, 10.0, 90.0, 90.0), point_frame(45.0, 50.0)], + &[story(WRAP_STORY, &[0], "/0 0", "/1 10.0"), story("Mid\\r", &[1], "/0 0", "/1 10.0")], + ); + let dict = "%_/ArtDictionary :\n%_0 /Bool (AI11TextWrapInvert) ,\n%_4 /Real (AI11TextWrapOffset) ,\n%_;\n%_\n"; + let layers = layer("Art", true, &format!("{}{}{dict}", frame_object(0, 0), frame_object(1, 0))); + let (d, notes) = crate::ai_alone(&private(&native_with_text(&layers, &doc))).unwrap(); + let kids = d.layers[0].children().unwrap(); + assert_eq!(kids.len(), 2, "{notes:?}"); + assert_eq!(kids[1].extras().wrap, Some(vectorcraft_doc::TextWrap { offset: 4.0, invert: false }), "{notes:?}"); + let NodeKind::Text(area) = &kids[0].kind else { panic!("area type") }; + assert_eq!(area.wrap.len(), 1, "the area type wraps around the text object above it"); +} + +/// A Large Canvas editing copy is written at 1/N scale: the wrap offset is scaled with the art. +#[test] +fn wrap_offset_on_a_large_canvas() { + let dict = "%_/ArtDictionary :\n%_0.5 /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n"; + let art = native_large_canvas(10.0, 10.0, &layer("Art", true, &format!("0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf\n{dict}"))); + let r = import(&eps(&page_square(50, 50), &art)).unwrap(); + let kids = r.document.layers[0].children().unwrap(); + assert_eq!(kids[0].extras().wrap.map(|w| w.offset), Some(5.0), "{:?}", r.warnings); + // A `.ai`'s art is scaled when its page is read in its units: the offset with it. + let art = native_large_canvas(100.0, 10.0, &layer("Art", true, &format!("{}\n{dict}", square(10, 10)))); + let (d, w, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(d.layers[0].children().unwrap()[0].extras().wrap.map(|w| w.offset), Some(0.5), "{w:?}"); +} + /// Replacing a group containing either threaded frame must use the PDF page instead. #[test] fn unread_group_holding_threaded_frame_falls_back() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8f5b2793d..17c94104d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -99,6 +99,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or a graphic after the text object of a legacy wraparound group (`*w` … `*W`), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow, Drop Shadow or Convert to Shape comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners or Scribble comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.