.ai import: type keeps its own opacity and blend mode

A .ai or EPS opened from its editing data lost the opacity and blend
mode of its text objects. text_slot made the slot without the graphics
state's opacity and blend mode, which paths, meshes and images take, so
an Xy before a text object was lost. An Xy after it reached the slot,
but fill_slots then put type made from the text document in the slot's
place with only its visibility and name, and style_marker dropped the
transparency that a text object with an appearance carries on its look.

text_slot now takes the state's opacity and blend mode. fill_slots
gives made type the slot's opacity, blend mode, isolation and knockout.
The page's type gets them too, in place of its own transparency when
that is none, the slot's, or the slot's combined with that of the
objects around it; otherwise the page's type and the strokes around it
keep their own (a fill's, for example). When the type has transparency
and comes with other art (the strokes the page draws around it, or page
type that goes with it), a group of them takes the transparency. A text
object with an appearance takes the transparency from its look; where
the look draws more than the type and has transparency, the look and
the type go into a group that has it, and that group is not taken as
the look of a later object. The import fuzz's .ai tokens now include a
commented text object, 1 /FreeUndo , and a group's state marker
(6 () XW).

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 14:46:20 +07:00
committed by bflatastic
parent f3072fc748
commit 3f81c70ade
6 changed files with 205 additions and 14 deletions
+3
View File
@@ -2495,6 +2495,9 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"0 0 /RealPoint (PositionPoint1) ,",
"/AI11Text :",
"0 /StoryIndex ,",
"1 /FreeUndo ,",
"6 () XW",
"\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\n",
"/SymbolInstance :",
"/Binary : /ASCII85Decode ,",
"~>",
+29 -6
View File
@@ -388,6 +388,8 @@ struct Reader<'a> {
looks: HashSet<NodeId>,
/// Legacy compound member groups, even when followed by the generic `9 () XW` marker.
compound_groups: HashSet<NodeId>,
/// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look.
text_looks: HashSet<NodeId>,
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
mesh: Option<(super::mesh::MeshReader, bool)>,
/// The art styles (`%AI9_BeginArtStyles`), by name.
@@ -532,6 +534,7 @@ impl<'a> Reader<'a> {
clip_fills: HashSet::new(),
looks: HashSet::new(),
compound_groups: HashSet::new(),
text_looks: HashSet::new(),
group_ended: false,
layer_state: None,
styles: HashMap::new(),
@@ -1323,6 +1326,9 @@ impl<'a> Reader<'a> {
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}")));
// 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;
let hidden = self.obj_hidden;
self.add(n, hidden)
}
@@ -1624,6 +1630,7 @@ impl<'a> Reader<'a> {
// The look is a group of paths, or one image where an effect (a blur, a shadow) rasterizes it.
if !self.hidden_ids.contains(&object.id)
|| self.hidden_ids.contains(&look.id) != hidden
|| self.text_looks.contains(&look.id)
|| !matches!(look.kind, NodeKind::Group { clip: false, .. } | NodeKind::Image(_))
{
return;
@@ -1641,7 +1648,7 @@ impl<'a> Reader<'a> {
return;
}
let is_text = slot_of(object.name.as_deref()).is_some();
let Some(object) = f.children.pop() else { return };
let Some(mut object) = f.children.pop() else { return };
let Some(look) = f.children.last_mut() else { return };
// Inside a copy on `%_` lines everything is, so nothing tells the look's own copies apart.
if !hidden {
@@ -1649,11 +1656,27 @@ impl<'a> Reader<'a> {
}
if is_text {
// A text object stays the type it is: over what else its look draws, or in place of a
// look that was only its type.
if look.children().is_some_and(|c| c.is_empty()) {
*look = object;
} else {
f.children.push(object);
// look that was only its type. Its transparency is written on its look.
let empty = look.children().is_some_and(|c| c.is_empty());
if empty || (look.opacity >= 1.0 && look.blend == BlendMode::Normal && !look.isolate) {
(object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout);
if empty {
*look = object;
} else {
f.children.push(object);
}
return;
}
// The look and the type go into a group that has the transparency.
let Some(mut look) = f.children.pop() else { return };
let state = (look.opacity, look.blend, look.isolate, look.knockout);
(look.opacity, look.blend, look.isolate, look.knockout) = (1.0, BlendMode::Normal, false, Knockout::Neutral);
(object.opacity, object.blend, object.isolate) = (1.0, BlendMode::Normal, false);
let mut group = Node::group(self.doc.alloc_id(), vec![Arc::new(look), Arc::new(object)]);
(group.opacity, group.blend, group.isolate, group.knockout) = state;
self.text_looks.insert(group.id);
if let Some(f) = self.frames.last_mut() {
f.children.push(group);
}
return;
}
+58 -7
View File
@@ -29,6 +29,7 @@ use std::borrow::Cow;
use std::collections::BTreeSet;
use std::sync::Arc;
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, Scaling, TextKind};
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
@@ -201,9 +202,19 @@ enum Content {
Merged,
}
/// Does `n` have transparency of its own: an opacity below 100%, a blend mode or isolation?
fn see_through(n: &Node) -> bool {
n.opacity < 1.0 || n.blend != BlendMode::Normal || n.isolate
}
/// Replace the text slots in `nodes` with what `content` has for each, in order (a slot given none is
/// left out): the page's type takes its place as it is, made type keeps the slot's name. `empty`
/// counts those left out.
/// left out): the page's type takes its place as it is, made type keeps the slot's name. Made type
/// takes the slot's opacity, blend mode, isolation and knockout. The page's type takes them too when
/// the page draws it and the strokes around it with no transparency, the slot's, or the slot's
/// combined with that of the objects around `nodes`; otherwise they keep their own. Where the slot has
/// transparency and the type comes with other art that has none of its own, a group of them takes the
/// slot's instead. `round` has the opacity and blend mode of each object around `nodes`, which the page
/// can have folded into its type. `empty` counts those left out.
fn fill_slots(
doc: &mut Document,
nodes: &mut Vec<Arc<Node>>,
@@ -211,6 +222,7 @@ fn fill_slots(
names: &std::collections::HashMap<NodeId, String>,
empty: &mut usize,
made: &mut Vec<(u32, usize, NodeId)>,
round: &mut Vec<(f32, BlendMode)>,
) {
let mut i = 0;
while i < nodes.len() {
@@ -219,10 +231,12 @@ fn fill_slots(
let visible = n.visible;
let name = names.get(&n.id);
let frame = ai::slot_frame(n.name.as_deref());
let state = (n.opacity, n.blend, n.isolate, n.knockout);
let transparent = see_through(n);
let replacement = match content.next() {
Some(Content::Page(parts)) if !parts.is_empty() => {
// The page's type (and the strokes round it) take the slot's place as they are.
let parts: Vec<Arc<Node>> = parts
let mut parts: Vec<Arc<Node>> = parts
.iter()
.map(|p| {
let mut p = reid(doc, p);
@@ -235,6 +249,31 @@ fn fill_slots(
p
})
.collect();
// When the page draws each of them with no transparency, the slot's, or the slot's
// combined with that of the objects around it, the slot's takes its place: on the one
// part, or on a group of several when the slot has transparency. Otherwise every part
// keeps the transparency the page draws it with (a fill's or a stroke's).
let above: f32 = round.iter().map(|r| r.0).product();
let objects = |p: &Node| {
!see_through(p)
|| (!p.isolate
&& (p.blend == BlendMode::Normal || p.blend == state.1 || round.iter().any(|r| r.1 == p.blend))
&& [state.0, state.0 * above].iter().any(|o| (p.opacity - o).abs() < 1e-3))
};
if parts.iter().all(|p| objects(p)) {
for p in &mut parts {
let p = Arc::make_mut(p);
(p.opacity, p.blend, p.isolate) = (1.0, BlendMode::Normal, false);
}
if let [p] = parts.as_mut_slice() {
let p = Arc::make_mut(p);
(p.opacity, p.blend, p.isolate, p.knockout) = state;
} else if transparent {
let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts));
(group.opacity, group.blend, group.isolate, group.knockout) = state;
parts.push(Arc::new(group));
}
}
let n = parts.len();
nodes.splice(i..=i, parts);
i += n;
@@ -248,8 +287,17 @@ fn fill_slots(
text.visible = visible;
// Type the page shows is kept; other made type is weighed against the page.
text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) };
let mut parts = vec![Arc::new(*text)];
parts.extend(extra.iter().map(|p| reid(doc, p)));
let extra: Vec<Arc<Node>> = extra.iter().map(|p| reid(doc, p)).collect();
// The type takes the slot's transparency; the page's type kept after it, where it has
// none of its own, goes into a group with it that has the transparency instead.
let parts: Vec<Arc<Node>> = if transparent && !extra.is_empty() && !extra.iter().any(|p| see_through(p)) {
let mut group = Node::group(doc.alloc_id(), std::iter::once(Arc::new(*text)).chain(extra).collect());
(group.opacity, group.blend, group.isolate, group.knockout) = state;
vec![Arc::new(group)]
} else {
(text.opacity, text.blend, text.isolate, text.knockout) = state;
std::iter::once(Arc::new(*text)).chain(extra).collect()
};
let n = parts.len();
nodes.splice(i..=i, parts);
i += n;
@@ -274,11 +322,14 @@ fn fill_slots(
}
}
} else {
let state = (n.opacity, n.blend);
if n.children().is_some()
&& !matches!(n.kind, NodeKind::Compound { .. })
&& let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut)
{
fill_slots(doc, c, content, names, empty, made);
round.push(state);
fill_slots(doc, c, content, names, empty, made, round);
round.pop();
}
i += 1;
}
@@ -1018,7 +1069,7 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr
let mut empty = 0;
let mut layers = std::mem::take(&mut doc.layers);
let mut made = vec![];
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made);
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made, &mut vec![]);
doc.layers = layers;
// A story in several frames is threaded type: its frames in order (the first holds the story).
made.sort();
+113
View File
@@ -598,6 +598,119 @@ fn type_the_page_draws_of_a_text_object_on_a_hidden_layer_stays_hidden() {
assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
}
/// The opacity and blend mode of a text object (an `Xy` before it, as a path has, or after it, as a
/// group has, or on the look of one with an appearance) are its type's, made from the text document
/// or taken from the page, and are applied once where the type comes with other art.
#[test]
fn type_keeps_the_opacity_and_blend_mode_of_its_text_object() {
use vectorcraft_color::BlendMode;
use vectorcraft_doc::Knockout;
let states = |d: &Document| {
let mut v = vec![];
d.walk(|n| {
if let NodeKind::Text(_) = &n.kind {
v.push((n.opacity, n.blend));
}
});
v
};
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n1 0.2 0 1 0 Xy\n0 0 Xd\n6 () XW\n";
let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
for body in [
format!("1 0.2 0 0 0 Xy\n{}", text_object(0)),
format!("{}1 0.2 0 0 0 Xy\n", text_object(0)),
format!("{look}%_0 1 0 0 0 Xy\n{own}1 (Style) XW\n"),
] {
let art = native_with_text(&layer("Art", true, &body), &doc);
// Made from the text document.
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
assert_eq!(states(&d), [(0.2, BlendMode::Multiply)], "{body}{notes:?}");
// From an EPS page, which draws it without transparency.
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{body}{:?}", r.warnings);
assert_eq!(states(&r.document), [(0.2, BlendMode::Multiply)], "{body}{:?}", r.warnings);
}
// A look with transparency that draws more than the type: the look and the type are in a group
// that has the transparency once, whatever state the object is read in.
let drawing = look.replace("U\n", &format!("{}\nU\n", square(10, 10)));
for reset in ["%_0 1 0 0 0 Xy\n", ""] {
let art = native_with_text(&layer("Art", true, &format!("{drawing}{reset}{own}1 (Style) XW\n")), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
assert_eq!(kinds(&d.layers[0]), ["group[2]"], "{reset}{notes:?}");
let group = &d.layers[0].children().unwrap()[0];
assert_eq!((group.opacity, group.blend, group.knockout), (0.2, BlendMode::Multiply, Knockout::On), "{reset}");
assert_eq!(kinds(group), ["group[1]", "text"], "{reset}");
assert_eq!(group.children().unwrap()[0].knockout, Knockout::Neutral, "{reset}");
assert!(group.children().unwrap().iter().all(|p| (p.opacity, p.blend) == (1.0, BlendMode::Normal)), "{reset}");
}
// Such a group isn't the look of a text object after it, so the groups don't nest.
fn depth(n: &Node) -> usize {
1 + n.children().map_or(0, |c| c.iter().map(|c| depth(c)).max().unwrap_or(0))
}
let art = native_with_text(&layer("Art", true, &(drawing.clone() + &format!("{own}1 (Style) XW\n").repeat(50))), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
assert_eq!(depth(&d.layers[0]), 4, "{notes:?}");
// Made type with the page's type of no text object kept after it: a group of the two has it.
let art = native_with_text(&layer("Art", true, &format!("1 0.2 0 0 0 Xy\n{}", text_object(0))), &doc);
let r = import(&eps(&format!("{page} 58.5 30 moveto (Extra) show"), &art)).unwrap();
assert_eq!(kinds(&r.document.layers[0]), ["group[2]"], "{:?}", r.warnings);
let group = &r.document.layers[0].children().unwrap()[0];
assert_eq!((group.opacity, group.blend), (0.2, BlendMode::Multiply));
let texts: Vec<String> = text_of(group).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["Hi there", "Extra"]);
assert!(group.children().unwrap().iter().all(|p| (p.opacity, p.blend) == (1.0, BlendMode::Normal)));
// A story in a frame of a kind this doesn't know: the type that shows is the page's. An EPS page has
// no transparency, so its type takes its text object's.
let doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /0 9 /2 [ 1 0 0 1 0 0 ] >>");
let art = native_with_text(&layer("Art", true, &format!("{}\n1 0.2 0 0 0 Xy\n{}", square(10, 10), text_object(0))), &doc);
let r = import(&eps(&(page_square(10, 10) + "\n" + page), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert_eq!(states(&r.document), [(0.2, BlendMode::Multiply)], "{:?}", r.warnings);
// A PDF part's type at its text object's transparency has it once; transparency of its own that
// the text object doesn't have (a fill's) stays.
let pdf = |state: (f32, BlendMode)| {
let mut pdf = pdf_part(10.0, 86.0);
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(58.5, 38.5), "Hi there", style);
let mut text = Node::new(pdf.alloc_id(), NodeKind::Text(Box::new(text)));
(text.opacity, text.blend) = state;
let layer_id = pdf.layers[0].id;
pdf.insert(Some(layer_id), 1, text).unwrap();
pdf
};
let (d, w, read) = crate::layered_ai(&private(&art), pdf((0.2, BlendMode::Multiply)), vec![], false, false, 1.0);
assert!(read, "{w:?}");
assert_eq!(states(&d), [(0.2, BlendMode::Multiply)], "{w:?}");
let plain = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), text_object(0))), &doc);
let (d, w, read) = crate::layered_ai(&private(&plain), pdf((0.7, BlendMode::Normal)), vec![], false, false, 1.0);
assert!(read, "{w:?}");
assert_eq!(states(&d), [(0.7, BlendMode::Normal)], "{w:?}");
// The same type with a look that draws more than it, which the page draws with the look's
// transparency: the group of the two has it once.
let art = native_with_text(&layer("Art", true, &format!("{drawing}{own}1 (Style) XW\n")), &doc);
let (d, w, read) = crate::layered_ai(&private(&art), pdf((0.2, BlendMode::Multiply)), vec![], false, false, 1.0);
assert!(read, "{w:?}");
assert_eq!(kinds(&d.layers[0]), ["group[2]"], "{w:?}");
let group = &d.layers[0].children().unwrap()[0];
assert_eq!((group.opacity, group.blend), (0.2, BlendMode::Multiply));
assert_eq!(states(&d), [(1.0, BlendMode::Normal)], "{w:?}");
// Page type that is a text and the path of its stroke: a group of the two takes the transparency.
let page = format!(
"{}\n/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show\n10 50 moveto (Hi) false charpath 2 setlinewidth stroke",
page_square(10, 10)
);
let words = layer("Words", true, "1 0.2 0 0 0 Xy\n/AI11Text :\n0 /FreeUndo ,\n;\n");
let r = import(&eps(&page, &native(&(layer("Art", true, &square(10, 10)) + &words)))).unwrap();
let words = &r.document.layers[1];
assert_eq!(kinds(words), ["group[2]"], "{:?}", r.warnings);
let group = &words.children().unwrap()[0];
assert_eq!((group.opacity, group.blend), (0.2, BlendMode::Multiply));
assert_eq!(kinds(group), ["text", "path"]);
assert!(group.children().unwrap().iter().all(|p| (p.opacity, p.blend) == (1.0, BlendMode::Normal)));
}
#[test]
fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() {
// The file writes some stories twice (plain, then as comments) and others only as comments.
+1 -1
View File
@@ -600,7 +600,7 @@ strokes (spot colours as spot swatches), linear and radial gradients, opacity, b
embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or
strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text
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), alignment,
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
layer, even where the page doesn't draw it. A file whose editing data has symbols,
+1
View File
@@ -622,6 +622,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`).
In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/text_layout.rs`).
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`).