Merge pull request #5505 from dotCooCoo/fix/ai-look-copies-type

.ai import: copies drawn by a layer's or group's look keep their type
This commit is contained in:
BFlat
2026-11-04 12:18:30 +07:00
committed by GitHub
13 changed files with 607 additions and 35 deletions
+2
View File
@@ -38,6 +38,8 @@ pub(crate) use frames::text_frames;
pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, read_beside, variables};
pub(crate) use read::{join_and, slot_frame, slot_of};
pub(crate) use read::is_look_slot;
#[cfg(test)]
mod tests;
+5
View File
@@ -354,6 +354,11 @@ fn marker(c: &str) -> &str {
word.strip_prefix("AI").map(|r| r.trim_start_matches(|ch: char| ch.is_ascii_digit())).and_then(|r| r.strip_prefix('_')).unwrap_or(word)
}
/// Is `name` that of a text slot for type that a look draws ([`document::LOOK_SLOT`])?
pub(crate) fn is_look_slot(name: Option<&str>) -> bool {
name == Some(document::LOOK_SLOT)
}
impl<'a> Reader<'a> {
fn new(data: &'a [u8]) -> Self {
let mut doc = Document::new(1.0, 1.0);
+12 -8
View File
@@ -15,17 +15,21 @@ impl<'a> Reader<'a> {
if self.pattern.is_some() {
self.unreadable("type in a pattern", false);
}
// `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its
// story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its
// stories on its own; the object itself follows the look, on `%_` lines, and is the type.
if o.nums("FreeUndo").first() == Some(&1.0) {
return Ok(());
}
// `1 /FreeUndo`: type in a drawn look. Its story is one of another text document
// (`/AI11UndoFreeTextDocument`), which counts its stories on its own, so its slot has none.
// In the look of a text object that has an appearance, the object itself follows the look,
// on `%_` lines, and is the type (`style_marker` takes the slot out); in the look of a layer
// or a group it is the type of a copy that the look draws.
let look = o.nums("FreeUndo").first() == Some(&1.0);
let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0));
let (story, frame) = (index("StoryIndex").filter(|_| !look), index("FrameIndex").unwrap_or(0));
let gs = self.gs.clone();
let mut n = self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs);
n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}:{frame}")));
n.name = Some(match story {
_ if look => super::document::LOOK_SLOT.to_string(),
Some(s) => format!("{TEXT_SLOT}{s}:{frame}"),
None => TEXT_SLOT.to_string(),
});
// The type takes the state's transparency, as a path does; an `Xy` after it sets the slot's own.
n.opacity = gs.opacity;
n.blend = gs.blend;
+5 -1
View File
@@ -662,8 +662,12 @@ fn has_text_slot(node: &Node) -> bool {
!slots.is_empty()
}
/// The source text inside an envelope options dictionary, carried through its containing metadata.
/// The source text inside an envelope options dictionary, carried through its containing metadata
/// (not type that a look draws, which isn't the envelope's source text).
fn envelope_text_slots(node: &Node, out: &mut Vec<Node>) {
if super::is_look_slot(node.name.as_deref()) {
return;
}
if slot_of(node.name.as_deref()).is_some() {
out.push(node.clone());
} else if let Some(children) = node.children() {
@@ -121,6 +121,10 @@ pub(crate) fn slot_frame(name: Option<&str>) -> usize {
name.and_then(|n| n.strip_prefix(TEXT_SLOT)).and_then(|rest| rest.split_once(':')).and_then(|(_, f)| f.parse().ok()).unwrap_or(0)
}
/// The name of the text slot for type that a look draws (`1 /FreeUndo`): [`TEXT_SLOT`] with no
/// story, as [`slot_of`] reads it.
pub(crate) const LOOK_SLOT: &str = "\u{0}text:look";
/// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't
/// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to
/// call them (#1068).
+22
View File
@@ -596,6 +596,18 @@ impl<'a> Reader<'a> {
{
self.looks.insert(look.id);
}
// The type that a text object's look draws is the object's own, whatever comes of its style.
if after
&& let Some(f) = self.frames.last_mut()
&& let [.., look, object] = f.children.as_mut_slice()
&& slot_of(object.name.as_deref()).is_some()
&& !super::is_look_slot(object.name.as_deref())
&& self.hidden_ids.contains(&object.id)
&& self.hidden_ids.contains(&look.id) == hidden
&& matches!(look.kind, NodeKind::Group { clip: false, .. })
{
strip_look_type(look);
}
if code != 1.0 || style.is_empty() || !after {
return;
}
@@ -890,6 +902,16 @@ fn with_look(layer: &mut AppearanceItem, look: &crate::import::ai::style::ItemLo
}
}
/// Remove from `n` the text slots of the type its look draws ([`super::is_look_slot`]), in its groups too.
fn strip_look_type(n: &mut Node) {
if let Some(children) = n.children_mut() {
children.retain(|c| !super::is_look_slot(c.name.as_deref()));
for c in children.iter_mut().filter(|c| c.children().is_some_and(|k| !k.is_empty())) {
strip_look_type(Arc::make_mut(c));
}
}
}
/// 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() {
+27
View File
@@ -2250,6 +2250,33 @@ fn text_objects_are_slots_named_after_their_story() {
let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n")));
let a = art(&r.doc);
assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7))));
// The type of the look in a group of it is the object's too.
let nested = look.replacen("u\n", "u\nu\n", 1).replacen("U\n", "U\nU\n", 1);
let r = read_ok(&layer(&format!("{nested}{own}1 (Style) XW\n")));
let mut slots = vec![];
r.doc.walk(|n| {
if let Some(story) = slot_of(n.name.as_deref()) {
slots.push(story);
}
});
assert_eq!(slots, [Some(7)]);
// Whatever comes of the object's style, the type of its look is the object's.
for marker in ["1 () XW", "2 (Style) XW"] {
let r = read_ok(&layer(&format!("{look}{own}{marker}\n")));
let mut looks = 0;
r.doc.walk(|n| looks += usize::from(super::is_look_slot(n.name.as_deref())));
assert_eq!(looks, 0, "{marker}");
}
// In the look of a group, the type is that of a copy the look draws: a slot without a story.
let group = "u\n%_/AI11Text :\n%_0 /FreeUndo ,\n%_0 /FrameIndex ,\n%_7 /StoryIndex ,\n%_;\n%_U\n";
let r = read_ok(&layer(&format!("{look}{group}1 (Style) XW\n")));
let mut slots = vec![];
r.doc.walk(|n| {
if slot_of(n.name.as_deref()).is_some() {
slots.push(n.name.clone());
}
});
assert!(slots.len() == 1 && slots.iter().all(|n| super::is_look_slot(n.as_deref())), "{slots:?}");
}
/// A file in the legacy format (version 7) whose layer holds `art`, crop marks 0 0 200 100.
+4 -2
View File
@@ -228,6 +228,7 @@ pub(super) fn build_from(
}
let mut infos = vec![];
collect_slots(&doc.layers, true, &mut infos);
super::slots::limit_look_slots(&mut doc.layers, &mut infos, shown.len(), outlined);
// Each text object's own frame or path, by its story and frame.
let frames = ai::text_frames(data);
for info in &mut infos {
@@ -959,8 +960,9 @@ fn slot_content(
return Ok((made_only(infos, made, [own, pages[1]], notes), true));
}
// The page's type is matched with every text object whose type isn't made, and weighed against
// where every made one draws: that has its limits.
let open = made.iter().filter(|m| m.is_none()).count();
// where every made one draws: that has its limits (the slots of type that looks draw are within
// them, see `build_from`).
let open = infos.iter().zip(&made).filter(|(i, m)| i.look.is_none() && m.is_none()).count();
if open > MAX_SLOTS || infos.len().saturating_mul(pages[0].len()) > MAX_PAIRS {
return Err("it has too many text objects".into());
}
+157 -21
View File
@@ -90,28 +90,173 @@ pub(super) fn make_slots(
made
}
/// Does the file's text document make the type of every slot that shows (at least one does)?
/// Does the file's text document make the type of every slot that shows (at least one does)? Type
/// that a look draws doesn't count: only the page has it.
pub(super) fn readable_all(infos: &[SlotInfo], made: &[Option<Made>]) -> bool {
infos.iter().any(|i| i.shown) && infos.iter().zip(made).all(|(i, m)| !i.shown || m.is_some())
let counted = |i: &SlotInfo| i.shown && i.look.is_none();
infos.iter().any(counted) && infos.iter().zip(made).all(|(i, m)| !counted(i) || m.is_some())
}
/// Each slot's made type, where its slot is (the page's type that shows isn't put anywhere). Shown
/// made type with none of the page's type where it draws is weighed against the page (see
/// `prune_unseen`); the page's type that no made type takes, shown or not, is left out with a note
/// in `warn`. Slots that don't show and whose type isn't made take the page's type that doesn't
/// show (`pages`: the page's text objects that show and that don't), by painting order.
/// Each slot's made type, where its slot is (the page's type that shows isn't put anywhere, but for
/// the type of the copies that looks draw, see [`fill_look_slots`]). Shown made type with none of
/// the page's type where it draws is weighed against the page (see `prune_unseen`); the page's type
/// that no made type takes, shown or not, is left out with a note in `warn`. Slots that don't show
/// and whose type isn't made take the page's type that doesn't show (`pages`: the page's text
/// objects that show and that don't), by painting order.
pub(super) fn made_only(infos: &[SlotInfo], made: Vec<Option<Made>>, pages: [&[Vec<Arc<Node>>]; 2], warn: &mut Vec<String>) -> Vec<Content> {
let [shown, hidden] = pages;
let mut content = made_content(infos, made);
match see_page_type(&mut content, shown) {
// The page's type where no made type draws, in the copies that looks draw, is theirs (past
// `MAX_PAIRS` comparisons it isn't looked for).
let looks = infos.iter().any(|i| i.shown && i.look.is_some());
let mut taken = std::collections::HashSet::new();
if looks && infos.len().saturating_mul(shown.len()) <= MAX_PAIRS {
let spelled: Vec<String> =
content.iter().map(|c| if let Content::Made(node, ..) = c { spelling(&plain_text(node)) } else { String::new() }).collect();
let made_type = |o: &[Arc<Node>]| covered(o, &content, |_| true) && has_made_words(o, &content, &spelled);
let mut spare: Vec<Vec<Arc<Node>>> = shown.iter().filter(|o| !made_type(o)).cloned().collect();
fill_look_slots(infos, &mut content, &mut spare);
for c in &content {
if let Content::Page(parts) = c {
taken.extend(parts.iter().map(|n| n.id));
}
}
}
let rest: Vec<Vec<Arc<Node>>>;
let untaken = if taken.is_empty() {
shown
} else {
rest = shown.iter().filter(|o| !o.iter().any(|n| taken.contains(&n.id))).cloned().collect();
&rest
};
match see_page_type(&mut content, untaken) {
Some(0) => {}
Some(_) => warn.push(PAGE_TYPE_LEFT_OUT.into()),
None => warn.push(PAGE_TYPE_UNCHECKED.into()),
}
drop_look_slots(infos, &mut content);
fill_hidden(infos, &mut content, hidden);
content
}
/// Give the page's type in `spare` that lies in a copy that a layer's or group's look draws (at least
/// half of it in the box of the copy's other art, or round it as far as strokes round type reach; of
/// overlapping copies, the one that holds the most of it, else the smallest) to that copy's text
/// slots that show, by painting order, taking it out of `spare`. Type further out than that stays in
/// `spare`. The other text objects in a copy's group that have
/// nothing yet take its type with them, in their order. Where the page's type outside the copies is
/// too little for the other text objects that have nothing yet, a copy with more of the page's text
/// objects than slots leaves the extra ones in `spare` for them. The look's other slots are dropped
/// ([`drop_look_slots`]).
fn fill_look_slots(infos: &[SlotInfo], content: &mut [Content], spare: &mut Vec<Vec<Arc<Node>>>) {
// Each copy's group and box, the slots in it that show and have nothing yet, in order, and the
// page's text objects (by their place in `spare`) it holds.
// A group has one box: that of its art, or of the group around it.
type Copy = (Option<NodeId>, Rect, Vec<usize>, Vec<usize>);
let mut copies: Vec<Copy> = vec![];
let mut by_group: std::collections::HashMap<Option<NodeId>, usize> = Default::default();
for info in infos {
let Some(copy) = info.look.filter(|r| info.shown && !r.is_zero_area()) else { continue };
by_group.entry(info.group).or_insert_with(|| {
copies.push((info.group, copy, vec![], vec![]));
copies.len() - 1
});
}
if copies.is_empty() || spare.is_empty() {
return drop_look_slots(infos, content);
}
for (k, (info, c)) in infos.iter().zip(content.iter()).enumerate() {
if let Some(copy) = by_group.get(&info.group).and_then(|i| copies.get_mut(*i)).filter(|_| info.shown && matches!(c, Content::Empty)) {
copy.2.push(k);
}
}
for (j, object) in spare.iter().enumerate() {
let Some(r) = extent(object) else { continue };
let share = |copy: &Rect| copy.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(r).area() / r.area().max(1e-9);
let best = copies
.iter_mut()
.map(|c| (share(&c.1), c))
.filter(|(s, c)| *s >= 0.5 && !c.2.is_empty())
.max_by(|a, b| a.0.total_cmp(&b.0).then_with(|| b.1.1.area().total_cmp(&a.1.1.area())));
if let Some((_, (.., held))) = best {
held.push(j);
}
}
// Copies with the same box share its type, by painting order.
let mut boxes: Vec<(Rect, Vec<usize>, Vec<usize>)> = vec![];
for (_, copy, slots, held) in copies {
match boxes.iter_mut().find(|b| b.0 == copy) {
Some(b) => {
b.1.extend(slots);
b.2.extend(held);
}
None => boxes.push((copy, slots, held)),
}
}
// Other text objects that show and have nothing yet, outside the copies.
let in_copy: std::collections::BTreeSet<usize> = boxes.iter().flat_map(|b| b.1.iter().copied()).collect();
let others: Vec<usize> = infos
.iter()
.zip(content.iter())
.enumerate()
.filter(|(k, (i, c))| i.shown && i.look.is_none() && matches!(c, Content::Empty) && !in_copy.contains(k))
.map(|(k, _)| k)
.collect();
// How many of them the page's type outside the copies leaves without any.
let outside = spare.len().saturating_sub(boxes.iter().map(|b| b.2.len()).sum());
let mut short = others.len().saturating_sub(outside);
let mut taken = vec![false; spare.len()];
for (_, slots, held) in &mut boxes {
held.sort_unstable();
// The page paints in the order of the layers: the extra ones go first to the other text objects
// before the copy, from the start, then to those after it, from the end.
let first = slots.first().copied().unwrap_or(0);
let extra = held.len().saturating_sub(slots.len()).min(short);
short -= extra;
let before = others.iter().filter(|k| **k < first).count().min(extra);
held.drain(..before);
held.truncate(held.len() - (extra - before));
let objects: Vec<Vec<Arc<Node>>> = held.iter().filter_map(|j| spare.get(*j).cloned()).collect();
for j in held.iter() {
if let Some(t) = taken.get_mut(*j) {
*t = true;
}
}
for (slot, parts) in slots.iter().zip(assign(&objects, slots.len())) {
if let Some(c) = content.get_mut(*slot).filter(|_| !parts.is_empty()) {
*c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect());
}
}
}
let mut kept = taken.iter();
spare.retain(|_| !kept.next().copied().unwrap_or(false));
drop_look_slots(infos, content);
}
/// Has made type that shows and draws where the page's text object `object` lies every word of it
/// ([`spelling`], each slot's in `spelled`), in any order?
fn has_made_words(object: &[Arc<Node>], content: &[Content], spelled: &[String]) -> bool {
let Some(r) = extent(object) else { return true };
let near: Vec<&String> = content
.iter()
.zip(spelled)
.filter(|(c, _)| matches!(c, Content::Made(_, Some(region), _) if !region.intersect(r).is_zero_area()))
.map(|(_, s)| s)
.collect();
object_words(object).iter().all(|w| near.iter().any(|s| s.contains(w.as_str())))
}
/// The text slots for type that a look draws and that have nothing have no type of their own to hold
/// (the page has it, or the text document of such type isn't read): they go, and aren't counted as
/// text objects that couldn't be read.
fn drop_look_slots(infos: &[SlotInfo], content: &mut [Content]) {
for (info, c) in infos.iter().zip(content) {
if info.look.is_some() && matches!(c, Content::Empty) {
*c = Content::Merged;
}
}
}
/// The letters and digits of `s`, in any script, in lower case: what is compared of the page's type
/// and made type (the page may spell a mark another way).
fn spelling(s: &str) -> String {
@@ -176,7 +321,8 @@ fn see_page_type(content: &mut [Content], shown: &[Vec<Arc<Node>>]) -> Option<us
/// What goes into each of `infos`' slots when the file's text document can't make the type of every
/// slot that shows: type made from the text document (`made`) where it can, else the page's type that
/// the text document puts where the page has it, else the page's type in painting order. The page's
/// the text document puts where the page has it, else the page's type in a copy that a layer's or
/// group's look draws (see [`fill_look_slots`]), else the page's type in painting order. The page's
/// type that lies where made type draws and has its words is that type as the page draws it; the
/// rest goes only into slots without made type.
pub(super) fn plan(
@@ -231,17 +377,6 @@ pub(super) fn plan(
_ => String::new(),
})
.collect();
// Has made type that draws where `object` lies every word of it ([`spelling`]), in any order?
let made_words = |object: &[Arc<Node>], content: &[Content]| {
let Some(r) = extent(object) else { return true };
let near: Vec<&String> = content
.iter()
.zip(&spelled)
.filter(|(c, _)| matches!(c, Content::Made(_, Some(region), _) if !region.intersect(r).is_zero_area()))
.map(|(_, s)| s)
.collect();
object_words(object).iter().all(|w| near.iter().any(|s| s.contains(w.as_str())))
};
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
for (j, object) in shown.iter().enumerate() {
match owner.get(j).copied().flatten() {
@@ -252,7 +387,7 @@ pub(super) fn plan(
},
// What lies where made type draws, with its words, is that type as the page draws it (in
// another font, maybe, and in any script).
None if covered(object, &content, |_| true) && made_words(object, &content) => {}
None if covered(object, &content, |_| true) && has_made_words(object, &content, &spelled) => {}
None if hidden_type(object) => {}
None => spare.push(object.clone()),
}
@@ -264,6 +399,7 @@ pub(super) fn plan(
join_pieces(parts, l);
}
}
fill_look_slots(infos, &mut content, &mut spare);
// What is left of the page goes by painting order into the shown slots the file can't say.
let loose: Vec<usize> =
infos.iter().enumerate().filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect();
+85 -2
View File
@@ -69,16 +69,64 @@ pub(super) struct SlotInfo {
pub(super) shown: bool,
/// Its own frame or path, where the file places it ([`ai::text_frames`]).
pub(super) art_path: Option<PathData>,
/// For type that a layer's or group's look draws ([`ai::is_look_slot`]): the box of the group
/// it is in, the copy of the art that the look draws (that of the group around it when it draws
/// nothing but type).
pub(super) look: Option<Rect>,
/// The group it is in.
pub(super) group: Option<NodeId>,
}
/// The text slots of the layers and objects, in order.
pub(super) fn collect_slots(nodes: &[Arc<Node>], shown: bool, out: &mut Vec<SlotInfo>) {
slots_in(nodes, shown, &mut vec![], &mut Default::default(), out);
}
/// [`collect_slots`] for `nodes` in the innermost of `groups`. The boxes of groups are measured
/// only for type that looks draw, once each (`boxes`).
fn slots_in<'a>(
nodes: &'a [Arc<Node>],
shown: bool,
groups: &mut Vec<&'a Node>,
boxes: &mut std::collections::HashMap<NodeId, Option<Rect>>,
out: &mut Vec<SlotInfo>,
) {
for n in nodes {
let shown = shown && n.visible;
if let Some(story) = slot_of(n.name.as_deref()) {
out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None });
// A group with nothing to draw but type is in the box of the group around it.
let look = ai::is_look_slot(n.name.as_deref())
.then(|| groups.iter().rev().find_map(|g| *boxes.entry(g.id).or_insert_with(|| g.visual_bounds())).unwrap_or(Rect::ZERO));
let group = groups.last().map(|g| g.id);
out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None, look, group });
} else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) {
collect_slots(c, shown, out);
groups.push(n);
slots_in(c, shown, groups, boxes, out);
groups.pop();
}
}
}
/// Past the limits on matching the page's type (`pieces` text objects) with the text slots `infos` of
/// `layers`, the slots of type that looks draw go: their copies' type is matched like any other of
/// the page's type. They also go when the page's type comes in as outlines (`outlined`), which leaves
/// them no type to hold.
pub(super) fn limit_look_slots(layers: &mut Vec<Arc<Node>>, infos: &mut Vec<SlotInfo>, pieces: usize, outlined: bool) {
let looks = infos.iter().filter(|i| i.look.is_some()).count();
if looks > 0 && (outlined || looks > MAX_SLOTS || infos.len().saturating_mul(pieces) > MAX_PAIRS) {
drop_look_type_slots(layers);
infos.retain(|i| i.look.is_none());
}
}
/// Remove the text slots of type that looks draw ([`ai::is_look_slot`]) from `nodes` and their groups.
fn drop_look_type_slots(nodes: &mut Vec<Arc<Node>>) {
nodes.retain(|n| !ai::is_look_slot(n.name.as_deref()));
for n in nodes.iter_mut() {
if n.children().is_some_and(|c| !c.is_empty())
&& let Some(c) = Arc::make_mut(n).children_mut()
{
drop_look_type_slots(c);
}
}
}
@@ -518,6 +566,41 @@ pub(super) fn extent(parts: &[Arc<Node>]) -> Option<Rect> {
mod tests {
use super::*;
/// Past the limits, the slots of type that looks draw go, in groups too, and other slots stay.
#[test]
fn look_type_slots_are_dropped_from_groups() {
let mut doc = Document::new(100.0, 100.0);
let mut slot = |name: &str| {
let mut n = Node::group(doc.alloc_id(), vec![]);
n.name = Some(name.into());
Arc::new(n)
};
let (look, own) = (slot("\u{0}text:look"), slot("\u{0}text0:0"));
let inner = Arc::new(Node::group(NodeId(90), vec![look.clone(), own.clone()]));
let mut nodes = vec![look, inner];
let collected = |nodes: &[Arc<Node>]| {
let mut infos = vec![];
collect_slots(nodes, true, &mut infos);
infos
};
let stories = |infos: &[SlotInfo]| infos.iter().map(|i| (i.story, i.look.is_some())).collect::<Vec<_>>();
// Within the limits, they stay.
let mut infos = collected(&nodes);
limit_look_slots(&mut nodes, &mut infos, 10, false);
assert_eq!(stories(&infos), [(None, true), (None, true), (Some(0), false)]);
assert_eq!(stories(&collected(&nodes)), stories(&infos));
// Three slots and a third of `MAX_PAIRS` pieces of the page's type, and more, are past them.
let mut past = nodes.clone();
let mut past_infos = collected(&past);
limit_look_slots(&mut past, &mut past_infos, MAX_PAIRS / 3 + 1, false);
assert_eq!(stories(&past_infos), [(Some(0), false)]);
assert_eq!(stories(&collected(&past)), stories(&past_infos));
// With the page's type as outlines, they go within the limits too.
limit_look_slots(&mut nodes, &mut infos, 10, true);
assert_eq!(stories(&infos), [(Some(0), false)]);
assert_eq!(stories(&collected(&nodes)), stories(&infos));
}
/// 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]
+280
View File
@@ -2384,6 +2384,286 @@ fn type_of_the_page_with_the_letters_of_two_made_types_where_it_lies_is_their_ty
assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw") || w.contains("with another's")), "{:?}", r.warnings);
}
/// A box from (`x0`, `y0`) to (`x1`, `y1`) in the format's operators, filled yellow.
fn filled_box(x0: u32, y0: u32, x1: u32, y1: u32) -> String {
format!("0 0 1 0 k\n{x0} {y0} m\n{x1} {y0} L\n{x1} {y1} L\n{x0} {y1} L\nf\n")
}
/// The type in a drawn look (`1 /FreeUndo`): a story of another text document.
const LOOK_TYPE: &str = "/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n";
/// The text of each text object in `n`, and whether it is in a group whose box is `around`.
fn texts_in(n: &Node, around: (f64, f64), out: &mut Vec<(String, bool)>) {
for c in n.children().into_iter().flatten() {
match &c.kind {
NodeKind::Text(t) => {
let inside = n.visual_bounds().is_some_and(|b| (b.x0 - around.0).abs() < 0.5 && (b.y0 - around.1).abs() < 7.0);
out.push((t.plain_text(), inside));
}
_ => texts_in(c, around, out),
}
}
}
#[test]
fn a_copy_that_a_layers_look_draws_takes_the_pages_type_of_that_copy() {
// GUARDS: type in a copy that a layer's or group's look draws had no text object, so the page's
// type was dealt by painting order into other groups, and the copy had none.
// The look: a group with a copy of a box and its type, 50 to the right of the box itself.
let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(60, 10, 90, 20));
let original = format!("0 Ae\nu\n{}{}U\n", text_object(0), filled_box(10, 10, 40, 20));
let art = native(&(layer("Art", true, &format!("{look}{original}")) + &layer("Words", true, &text_object(1))));
let page = "0 0 1 0 setcmykcolor 60 10 moveto 90 10 lineto 90 20 lineto 60 20 lineto closepath fill \
10 10 moveto 40 10 lineto 40 20 lineto 10 20 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 61 12 moveto (Copy) show 11 12 moveto (Box) show 10 80 moveto (Other) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
// Art y 10..20 is 80..90 down the page.
let mut texts = vec![];
texts_in(&r.document.layers[0], (60.0, 80.0), &mut texts);
assert_eq!(texts, [("Copy".to_string(), true), ("Box".to_string(), false)], "{:?}", r.warnings);
assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Other"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings);
}
/// A `.ai` whose page (its PDF part) has a yellow box from (10, 70) to (50, 90), and the type
/// `text` at (12, 84) when there is one.
fn pdf_box_with_type(text: Option<&str>) -> Document {
let mut d = Document::new(100.0, 100.0);
let layer_id = d.layers[0].id;
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 70.0, 50.0, 90.0));
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0));
let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint));
let square = Node::path(d.alloc_id(), path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() });
d.insert(Some(layer_id), 0, square).unwrap();
if let Some(text) = text {
let style = vectorcraft_doc::CharStyle { size: 6.0, ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(12.0, 84.0), text, style);
let id = d.alloc_id();
d.insert(Some(layer_id), 1, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap();
}
d
}
#[test]
fn a_copy_that_a_look_draws_in_an_ai_file_takes_the_type_of_its_pdf_part() {
let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(10, 10, 50, 30));
let art = native(&layer("Art", true, &look));
let (d, w, read) = crate::layered_ai(&private(&art), pdf_box_with_type(Some("Copy")), vec![], false, false, 1.0);
assert!(read, "{w:?}");
let mut texts = vec![];
texts_in(&d.layers[0], (10.0, 70.0), &mut texts);
assert_eq!(texts, [("Copy".to_string(), true)], "{w:?}");
assert!(!w.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{w:?}");
// With its type asked for as outlines, the copy has no type to hold, and the layers still come in.
let (d, w, read) = crate::layered_ai(&private(&art), pdf_box_with_type(None), vec![], true, false, 1.0);
assert!(read, "{w:?}");
assert!(all_texts(&d).is_empty(), "{w:?}");
}
#[test]
fn the_pages_type_beside_a_copys_art_goes_by_painting_order() {
// The copy's box is that of its art: type beside it, not in it, goes to the other layer's text
// object, by painting order.
let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(10, 10, 50, 30));
let art = native(&(layer("Art", true, &look) + &layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n")));
let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 70 15 moveto (Beside) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
assert!(text_of(&r.document.layers[0]).is_empty() && all_texts(&r.document) == ["Beside"], "{:?}", r.warnings);
assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Beside"], "{:?}", r.warnings);
}
#[test]
fn a_copy_in_the_place_of_made_type_with_its_words_takes_none_of_it() {
// A copy that a layer's look draws where made type draws, and the page's type there once, with
// the made type's words: that type is the made type as the page draws it, not the copy's.
let doc = text_document(&[("Other\r", 0, (8201.5, 8231.5), (0.0, 0.0), 0.0)]);
let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(56, 4, 100, 30));
let art = native_with_text(&(layer("Art", true, &look) + &layer("Words", true, &text_object(0))), &doc);
let page = "0 0 1 0 setcmykcolor 56 4 moveto 100 4 lineto 100 30 lineto 56 30 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 60 10 moveto (Other) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
assert_eq!(all_texts(&r.document), ["Other"], "{:?}", r.warnings);
assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Other"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", r.warnings);
}
#[test]
fn a_text_object_beside_a_copys_type_keeps_the_pages_type() {
// A group with type that a look draws and a text object of its own whose story isn't read: the
// page's type there goes to both by painting order, and to the text object when there is only
// one.
let group = format!("u\n{LOOK_TYPE}/AI11Text :\n0 /FreeUndo ,\n;\n{}U\n", filled_box(10, 10, 50, 30));
let art = native(&layer("Art", true, &group));
let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 22 moveto (Copy) show 12 15 moveto (Own) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let mut texts = vec![];
texts_in(&r.document.layers[0], (10.0, 70.0), &mut texts);
assert_eq!(texts, [("Copy".to_string(), true), ("Own".to_string(), true)], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings);
let page = page.replace("12 22 moveto (Copy) show ", "");
let r = import(&eps(&page, &art)).unwrap();
let group = &r.document.layers[0].children().unwrap()[0];
assert_eq!(kinds(group), ["text", "path"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings);
}
#[test]
fn a_copy_takes_no_more_of_the_pages_type_than_it_has_text_objects() {
// A copy with one text object, and a text object on another layer over it: the page's type there
// goes one to each, by painting order.
let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(10, 10, 50, 30));
let art = native(&(layer("Art", true, &look) + &layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n")));
let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 22 moveto (Copy) show 12 15 moveto (Over) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
let mut texts = vec![];
texts_in(&r.document.layers[0], (10.0, 70.0), &mut texts);
assert_eq!(texts, [("Copy".to_string(), true)], "{:?}", r.warnings);
assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Over"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings);
// With the other layer below, the page paints its type first.
let art = native(&(layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n") + &layer("Art", true, &look)));
let page = page.replace("(Copy) show 12 15 moveto (Over)", "(Over) show 12 15 moveto (Copy)");
let r = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&r.document), ["Words", "Art"], "{:?}", r.warnings);
let mut texts = vec![];
texts_in(&r.document.layers[1], (10.0, 70.0), &mut texts);
assert_eq!(texts, [("Copy".to_string(), true)], "{:?}", r.warnings);
assert_eq!(text_of(&r.document.layers[0]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Over"], "{:?}", r.warnings);
// With a layer below and one above, the copy's type is the middle one.
let words = |name: &str| layer(name, true, "/AI11Text :\n0 /FreeUndo ,\n;\n");
let art = native(&(words("Below") + &layer("Art", true, &look) + &words("Above")));
let page = page.replace("12 15 moveto (Copy) show", "12 15 moveto (Copy) show 12 12 moveto (Top) show");
let r = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&r.document), ["Below", "Art", "Above"], "{:?}", r.warnings);
let mut texts = vec![];
texts_in(&r.document.layers[1], (10.0, 70.0), &mut texts);
assert_eq!(texts, [("Copy".to_string(), true)], "{:?}", r.warnings);
let of = |l: usize| text_of(&r.document.layers[l]).iter().map(|t| t.plain_text()).collect::<Vec<_>>();
assert_eq!((of(0), of(2)), (vec!["Over".to_string()], vec!["Top".to_string()]), "{:?}", r.warnings);
}
#[test]
fn a_copy_takes_all_its_lines_when_every_other_text_object_is_made() {
// The page draws the copy's type as two text objects (two lines); the text object on the other
// layer is made from the text document.
let doc = text_document(&[("Other\r", 0, (8201.5, 8231.5), (0.0, 0.0), 0.0)]);
let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(10, 40, 50, 70));
let art = native_with_text(&(layer("Art", true, &look) + &layer("Words", true, &text_object(0))), &doc);
let page = "0 0 1 0 setcmykcolor 10 40 moveto 50 40 lineto 50 70 lineto 10 70 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 60 moveto (One) show 12 50 moveto (Two) show \
/Helvetica findfont 14 scalefont setfont 60 10 moveto (Other) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
let mut texts = vec![];
// Art y 40..70 is 30..60 down the page.
texts_in(&r.document.layers[0], (10.0, 30.0), &mut texts);
assert_eq!(texts, [("One".to_string(), true), ("Two".to_string(), true)], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", r.warnings);
// The same with a text object elsewhere whose story isn't read, and its type on the page.
let art = native(&(layer("Art", true, &look) + &layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n")));
let r = import(&eps(page, &art)).unwrap();
let mut texts = vec![];
texts_in(&r.document.layers[0], (10.0, 30.0), &mut texts);
assert_eq!(texts, [("One".to_string(), true), ("Two".to_string(), true)], "{:?}", r.warnings);
assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Other"], "{:?}", r.warnings);
}
#[test]
fn copies_in_the_same_place_take_the_pages_type_by_painting_order() {
let copy = format!("u\n{LOOK_TYPE}{}U\n", filled_box(10, 10, 50, 30));
let art = native(&layer("Art", true, &format!("0 Ae\nu\n{copy}{copy}U\n")));
let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 22 moveto (One) show 12 15 moveto (Two) show";
let r = import(&eps(page, &art)).unwrap();
let look = &r.document.layers[0].children().unwrap()[0];
let texts: Vec<Vec<String>> = look.children().unwrap().iter().map(|g| text_of(g).iter().map(|t| t.plain_text()).collect()).collect();
assert_eq!(texts, [["One"], ["Two"]], "{:?}", r.warnings);
}
#[test]
fn overlapping_copies_that_a_look_draws_each_take_their_own_type() {
// Two copies whose boxes overlap (10..50 and 30..60 across), and a third that draws only type,
// in a group with a box of its own (10..90 across, 60..80 up).
let a = format!("u\n{LOOK_TYPE}{}U\n", filled_box(10, 10, 50, 30));
let b = format!("u\n{LOOK_TYPE}{}U\n", filled_box(30, 10, 60, 30));
let only_type = format!("u\n{}u\n{LOOK_TYPE}U\nU\n", filled_box(10, 60, 90, 80));
let art = native(&layer("Art", true, &format!("0 Ae\nu\n{a}{b}{only_type}U\n")));
let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \
30 10 moveto 60 10 lineto 60 30 lineto 30 30 lineto closepath fill \
10 60 moveto 90 60 lineto 90 80 lineto 10 80 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 15 moveto (Aye) show 34 15 moveto (Bee) show 20 70 moveto (Sea) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
// Art x 10 and 30 at y 10..30 are 70..90 down the page; the third copy's type is alone in its
// group, at x 20 and about 26 down.
for (around, text) in [((10.0, 70.0), "Aye"), ((30.0, 70.0), "Bee"), ((20.0, 26.0), "Sea")] {
let mut texts = vec![];
texts_in(&r.document.layers[0], around, &mut texts);
let inside: Vec<&str> = texts.iter().filter(|(_, i)| *i).map(|(t, _)| t.as_str()).collect();
assert_eq!(inside, [text], "{texts:?} {:?}", r.warnings);
}
assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings);
}
#[test]
fn a_copy_over_made_type_takes_the_pages_type_with_other_words() {
// Made type at (60, 10), and a copy that a layer's look draws over it, whose type has other words.
let doc = text_document(&[("Other\r", 0, (8201.5, 8231.5), (0.0, 0.0), 0.0)]);
let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(56, 4, 100, 30));
let art = native_with_text(&(layer("Art", true, &look) + &layer("Words", true, &text_object(0))), &doc);
let page = "0 0 1 0 setcmykcolor 56 4 moveto 100 4 lineto 100 30 lineto 56 30 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 60 10 moveto (Other) show 60 12 moveto (Copy) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
let mut texts = vec![];
// Art y 4..30 is 70..96 down the page.
texts_in(&r.document.layers[0], (56.0, 70.0), &mut texts);
assert_eq!(texts, [("Copy".to_string(), true)], "{:?}", r.warnings);
assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Other"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", r.warnings);
}
#[test]
fn copies_that_a_groups_look_draws_take_the_pages_type_beside_made_type() {
// A group whose style isn't read keeps its drawn look: the box and a copy of it 50 down and to
// the left, each with its type. The group itself follows on `%_` lines. Type on another layer
// is made from the text document.
let doc = text_document(&[("Box\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Other\r", 0, (8201.5, 8231.5), (0.0, 0.0), 0.0)]);
let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(54, 56, 100, 80), filled_box(4, 6, 50, 30));
let own: String = format!("u\n{}{}U\n", text_object(0), filled_box(54, 56, 100, 80)).lines().map(|l| format!("%_{l}\n")).collect();
let art = native_with_text(&(layer("Art", true, &format!("{look}{own}1 (Shadow) XW\n")) + &layer("Words", true, &text_object(1))), &doc);
let page = "0 0 1 0 setcmykcolor 54 56 moveto 100 56 lineto 100 80 lineto 54 80 lineto closepath fill \
4 6 moveto 50 6 lineto 50 30 lineto 4 30 lineto closepath fill \
0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Box) show 8.5 11.5 moveto (Box) show 60 10 moveto (Other) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
// Art y 6..30 is 70..94 down the page; y 56..80 is 20..44.
let mut texts = vec![];
texts_in(&r.document.layers[0], (4.0, 70.0), &mut texts);
assert_eq!(texts, [("Box".to_string(), false), ("Box".to_string(), true)], "{:?}", r.warnings);
let mut texts = vec![];
texts_in(&r.document.layers[0], (54.0, 20.0), &mut texts);
assert_eq!(texts, [("Box".to_string(), true), ("Box".to_string(), false)], "{:?}", r.warnings);
assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Other"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings);
// Without a page, the copies have no type to take, and their text objects aren't ones that
// couldn't be read.
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
let mut texts = vec![];
texts_in(&d.layers[0], (4.0, 70.0), &mut texts);
assert!(texts.is_empty(), "{texts:?} {notes:?}");
assert_eq!(text_of(&d.layers[1]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Other"], "{notes:?}");
assert!(!notes.iter().any(|n| n.contains("left out")), "{notes:?}");
}
/// The file's text document is trusted where the page has type where it lays type out, whatever
/// letters that type has: the type is the text document's, and the difference is noted as type.
/// The page's type with other words is left out, with a note that is a loss.
+3 -1
View File
@@ -759,7 +759,9 @@ strokes, effects or a brush comes in as its drawn look (a group named after it).
document, whole and editable: point type, area type (a story in several frames as threaded type) and type on a path,
with its fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), opacity, blend mode, alignment,
indents and paragraph spacing. Where the text document can't say, type that shows is the page's, in its text object's
place. A text object on a layer that shows and doesn't print keeps the type the text document makes for it, on that
place. Where a layer's or group's drawn look draws a copy of art with type, the import puts the page's type that
lies over that copy's other art into the copy's group, unless the file is past the import's text-object limits.
A text object on a layer that shows and doesn't print keeps the type the text document makes for it, on that
layer, even where the page doesn't draw it. A file whose editing data can't be decompressed or has symbols,
pattern fills, placed files or anything else the reader doesn't read on a layer that shows, or whose layers look
different from its page, opens as before: an EPS as its printed page, a `.ai` file from its PDF content (where plain
+1
View File
@@ -1133,6 +1133,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Legacy `.ai` saves (version 7) write type with rotated characters (Character Rotation, Touch Type turns) as its outlines, with a warning that it is no longer editable text (unless `outlineText` already asks for every type object as outlines): a version 7 text object has no character rotation ([Adobe Illustrator File Format Specification 7.0](https://www.nationalarchives.gov.uk/PRONOM/fmt/423), §15), and the outlines keep each character's rotation, baseline shift and scale (`legacy_ai::tests::type_with_rotated_characters_is_outlines_that_keep_the_rotation`).
A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304).
In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`).
In a `.ai` or EPS opened from its editing data, where a layer's or group's drawn look draws a copy of art with type, the import puts the page's type that lies over the copy's other art into the copy's group, instead of giving it to other text objects by painting order. Type beside the copy's art, and every copy's type in a file past the import's text-object limits, still goes by painting order. Without a page, such a copy has no type, and no warning counts it as left out (`crates/eps/src/import/tests_native.rs`).
A `.ai` opened from its editing data keeps Effect › Pathfinder on a group with no fills or strokes of its own as a live effect, with the group's members editable: Add, shown or hidden, in its order among the group's effects. The other operations, Pathfinder Options values other than the ones the reader accepts, and the effect on a path, on type, or on a fill or stroke open as the drawn look (`crates/eps/src/import/ai/style.rs`, `pathfinder_effect`).
PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9).
A `.ai` or PDF whose editing data holds a readable variable library opens with its variable definitions, data-set rows, text, visibility and linked-file values, active row and lock in the Variables panel, whether its layers are read or it comes in as its PDF part; graph values and malformed entries are skipped with a note, and objects aren't bound yet (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).