.ai save: the editing copy stops at the reader's object limit

The reader stops a whole file past 4,000,000 objects (MAX_NODES), and
the writer had no limit of its own. The writer now counts the objects
it writes as the reader counts them, across layers, symbol and pattern
definitions, opacity masks and art styles, and leaves out art past the
limit with a warning. Art that does not fit is given back as a whole:
a mask, a pattern or a symbol definition is left out with all its art,
and a symbol's definition is written only with room for its instance.
The limit is the reader's own constant, re-exported as
MAX_EDITING_OBJECTS.
This commit is contained in:
dotCooCoo
2026-11-08 04:38:15 -08:00
parent b72dc56660
commit 9316c17967
12 changed files with 472 additions and 52 deletions
+8 -1
View File
@@ -167,7 +167,8 @@
//! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on
//! layers and opacity masks nested too deep, and, on objects written without an art style, live
//! effects other than geometry effects left out; graphic styles, links to them and every art style
//! on a large canvas are left out.
//! on a large canvas are left out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all
//! (the most the reader reads), counted as the reader counts them, is left out.
//!
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
//! the version numbers of the newest files users sent ([`private_data`]).
@@ -240,6 +241,11 @@ pub struct AiEditingOutput {
/// Write `doc` as the editing data of a `.ai` file: every artboard and layer.
pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditingOutput, String> {
export_within(doc, o, crate::import::MAX_EDITING_OBJECTS)
}
/// [`export_ai_editing`] writing at most `max_objects` objects, counted as the reader counts them.
fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Result<AiEditingOutput, String> {
let board = doc.artboards.first().ok_or("the document has no artboard")?.rect;
if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) {
return Err("the document has no artboard".into());
@@ -280,6 +286,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
// On a large canvas the layers' symbol instances are written as their art.
w.symbols_live = scale == 1.0;
w.symbol_doc = symbol_doc;
w.max_objects = max_objects;
w.reserve_swatch_gradients();
if scale == 1.0 {
w.styled = styled;
+162
View File
@@ -833,6 +833,168 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() {
assert_eq!(layers.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written");
}
/// The documents [`the_writer_counts_objects_as_the_reader_reads_them`] writes: groups, compound
/// paths, guides, sublayers, clipping groups and layers, painted clipping paths, opacity masks,
/// gradients, pattern definitions and tiles, images, type, symbol definitions and instances,
/// objects with art styles and objects bound to variables.
fn counted() -> Vec<(&'static str, Document)> {
vec![
("sample", sample()),
("unsupported", unsupported()),
("transparent", transparent()),
("gradients and patterns", gradients_and_patterns()),
("images", images_doc()),
("typed", typed()),
("variables", variables_doc()),
("symbols", symbols_document()),
("styles", styles_document()),
("painted clips", painted_clips()),
("blend", blend_doc()),
]
}
/// A blend of two squares 20 pt apart with a step every 10 pt, written as its steps.
fn blend_doc() -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let keys = vec![
Arc::new(square(&mut doc, Rect::new(10.0, 10.0, 12.0, 12.0), Color::gray(0.5))),
Arc::new(square(&mut doc, Rect::new(30.0, 10.0, 32.0, 12.0), Color::gray(0.5))),
];
let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() };
let blend = Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) });
doc.insert(Some(layer), usize::MAX, blend).unwrap();
doc
}
/// Clipping groups whose clipping paths are filled: a square with a color, and a ring (a path of
/// two subpaths) with a color, with a gradient, and with a color and a gradient stroke.
fn painted_clips() -> Document {
let mut doc = Document::new(260.0, 100.0);
let layer = doc.layers[0].id;
let ring = |x: f64| {
let mut p = shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0));
p.subpaths.extend(shapes::rectangle(Rect::new(x + 5.0, 15.0, x + 15.0, 25.0)).subpaths);
p
};
let g = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))],
);
let shade = || Paint::Gradient(Box::new(GradientPaint::new(g.clone())));
let clips = [
(10.0, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None),
(70.0, ring(70.0), solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None),
(130.0, ring(130.0), shade(), Paint::None),
(190.0, ring(190.0), solid(Color::rgb(0.0, 1.0, 0.0)), shade()),
];
for (x, path, fill, stroke) in clips {
let mut clip = clipping(&mut doc, path);
let width = if matches!(stroke, Paint::None) { 0.0 } else { 2.0 };
clip.appearance = Appearance::basic(fill, stroke, width).into();
let inside = square(&mut doc, Rect::new(x - 5.0, 5.0, x + 10.0, 20.0), Color::gray(0.5));
let g = group(&mut doc, vec![clip, inside], true);
doc.insert(Some(layer), usize::MAX, g).unwrap();
}
doc
}
/// The writer counts the objects it writes as the reader counts the objects it reads: with room
/// for as many objects as the reader reads, nothing is left out; with one less, art is left out,
/// with a warning. With room for fewer, the reader never reads more objects than there is room for.
#[test]
fn the_writer_counts_objects_as_the_reader_reads_them() {
let objects = |out: &AiEditingOutput| crate::import::objects_read(&out.program).unwrap();
let limited = |out: &AiEditingOutput| out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT);
for (name, doc) in counted() {
let all = export_ai_editing(&doc, &opts()).unwrap();
let read = objects(&all);
assert!(read > 0 && !limited(&all), "{name}: {read} objects, {:?}", all.warnings);
let room = export_within(&doc, &opts(), read).unwrap();
assert!(!limited(&room), "{name}: nothing is left out with room for {read} objects");
assert_eq!(room.program, all.program, "{name}");
for max in 0..read {
let out = export_within(&doc, &opts(), max).unwrap();
assert!(limited(&out), "{name}: art is left out with room for {max} of {read} objects");
assert!(objects(&out) <= max, "{name}: {} objects read with room for {max}", objects(&out));
}
}
}
/// An opacity mask whose art doesn't all fit within the object limit is left out with its art,
/// and the object it masks is written without it; the art left out leaves room for the objects
/// after it.
#[test]
fn a_mask_whose_art_passes_the_object_limit_is_left_out() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5));
let a = square(&mut doc, Rect::new(0.0, 0.0, 30.0, 30.0), Color::rgb(1.0, 1.0, 1.0));
let b = square(&mut doc, Rect::new(30.0, 30.0, 60.0, 60.0), Color::rgb(1.0, 1.0, 1.0));
let art = group(&mut doc, vec![a, b], false);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
doc.insert(Some(layer), usize::MAX, masked).unwrap();
let after = square(&mut doc, Rect::new(100.0, 10.0, 140.0, 50.0), Color::gray(0.5));
doc.insert(Some(layer), usize::MAX, after).unwrap();
let text = |max: usize| {
let out = export_within(&doc, &opts(), max).unwrap();
let limited = out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT);
(String::from_utf8_lossy(&out.program).replace("\r\n", "\n"), limited)
};
let (all, limited) = text(4);
assert!(!limited && all.contains("(AI9_Mask)"), "{all}");
let (short, limited) = text(2);
assert!(limited);
assert!(!short.contains("(AI9_Mask)") && !short.contains("%_/ArtDictionary :\n%_/Mask"), "{short}");
assert_eq!(short.matches("\nf\n").count(), 2, "both squares are written: {short}");
}
/// A symbol's definition is written only when there is room for its instance too.
#[test]
fn a_symbol_definition_is_written_only_with_room_for_its_instance() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let a = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::gray(0.5));
let b = square(&mut doc, Rect::new(10.0, 10.0, 20.0, 20.0), Color::gray(0.5));
let art = group(&mut doc, vec![a, b], false);
doc.symbols.push(Symbol { dynamic: false, name: "Pair".into(), art: Arc::new(art) });
let placed = instance(&mut doc, "Pair", Affine::translate((50.0, 10.0)));
doc.insert(Some(layer), usize::MAX, placed).unwrap();
add_rect(&mut doc, Rect::new(100.0, 10.0, 110.0, 20.0));
let program = export_ai_editing(&doc, &opts()).unwrap().program;
let all = String::from_utf8_lossy(&program).replace("\r\n", "\n");
assert!(all.contains("%AI14_BeginSymbol") && all.contains("/SymbolInstance :"), "{all}");
let read = crate::import::objects_read(&program).unwrap();
for max in 0..read {
let out = export_within(&doc, &opts(), max).unwrap();
let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n");
let defined = text.contains("%AI14_BeginSymbol");
assert_eq!(defined, text.contains("/SymbolInstance :"), "room for {max} of {read}: {text}");
}
}
/// The writer's limit is the reader's, which its warning names.
#[test]
fn the_object_limit_is_the_readers() {
assert_eq!(crate::import::MAX_EDITING_OBJECTS, 4_000_000);
assert!(writer::OBJECT_LIMIT.contains("4,000,000"));
}
/// Paths past the object limit are left out of a layer of many paths, with a warning: the paths
/// that fit are written.
#[test]
fn paths_past_the_object_limit_are_left_out() {
let mut doc = Document::new(200.0, 100.0);
for i in 0..10 {
add_rect(&mut doc, Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0));
}
let out = export_within(&doc, &opts(), 6).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings);
assert_eq!(crate::import::objects_read(&out.program).unwrap(), 6);
let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n");
assert_eq!(text.matches("\nf\n").count(), 6, "the first six paths are written");
}
#[test]
fn a_stained_symbol_instance_is_written_in_its_stained_colors() {
let mut doc = Document::new(200.0, 100.0);
+225 -43
View File
@@ -62,6 +62,7 @@ const GROUPED_LAYER: &str =
pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps";
pub(super) const SWATCHES: &str = "freeform gradient swatches, gradient swatches of more than 256 stops, and pattern swatches whose patterns the data can't define, are left out of the editing data for other apps";
pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps";
pub(super) const OBJECT_LIMIT: &str = "art past 4,000,000 objects in all is left out of the editing data for other apps";
const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps";
/// The transparency `mode opacity isolate knockout shape Xy` sets: the blending mode ([`blend_code`]),
@@ -117,6 +118,16 @@ fn blend_code(b: BlendMode) -> u8 {
}
}
/// What the writer had charged when a part of the art started ([`Writer::charges`]).
#[derive(Clone, Copy)]
struct Charges {
objects: usize,
symbol_nodes: u64,
kept_objects: usize,
kept_symbol_nodes: u64,
left_out: usize,
}
pub(super) struct Writer<'a> {
doc: &'a Document,
/// The document → art space.
@@ -144,6 +155,18 @@ pub(super) struct Writer<'a> {
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`].
symbol_nodes: u64,
/// The objects written so far, counted as the reader counts the objects it reads.
objects: usize,
/// How many times art was left out for [`Self::max_objects`].
left_out: usize,
/// The objects of the pattern and symbol definitions written so far, which stay written when
/// the art that first used them is not ([`Self::give_back`]).
definition_objects: usize,
/// The objects of symbol art in those definitions ([`Self::symbol_nodes`]).
definition_symbol_nodes: u64,
/// The most objects written ([`Self::take_objects`]): the most the reader reads,
/// [`crate::import::MAX_EDITING_OBJECTS`].
pub(super) max_objects: usize,
/// The document → the space gradients' matrices (`Xm`, `Bm`, `Bc`) and focal points are
/// written in: art space, and in a pattern's definition [`Self::tile_grad_space`].
grad_space: Affine,
@@ -286,6 +309,85 @@ impl<'a> Writer<'a> {
styled: HashMap::new(),
styles: styles::Styles::default(),
in_look: false,
objects: 0,
left_out: 0,
definition_objects: 0,
definition_symbol_nodes: 0,
max_objects: crate::import::MAX_EDITING_OBJECTS,
}
}
/// Whether `count` more objects may be written: the objects written so far, counted as the
/// reader counts them ([`object_count`]), stay within [`Self::max_objects`]. Past it the art is
/// left out, with a warning.
fn take_objects(&mut self, count: usize) -> bool {
let total = self.objects.saturating_add(count);
if total > self.max_objects {
self.warn(OBJECT_LIMIT);
self.left_out += 1;
return false;
}
self.objects = total;
true
}
/// Start a group with `op` (`u`, or `q` for a clipping group), counted as one object
/// ([`Self::take_objects`]). False, writing nothing, when there is no room for it.
fn open_group(&mut self, op: &str) -> bool {
if !self.take_objects(1) {
return false;
}
self.line(op);
true
}
/// Whether `n`, an instance of `symbol` placed by `xf`, is written as a live instance
/// ([`Self::live_instance`]). The instance is one object, without the art of its definition,
/// and room for it is kept before the definition is written.
fn live_instance_in_room(&mut self, n: &Node, symbol: &str, xf: Affine) -> bool {
if !self.take_objects(1) {
return false;
}
if self.live_instance(n, symbol, xf) {
return true;
}
self.objects -= 1;
false
}
/// What has been charged so far, for [`Self::keep_since`] and [`Self::give_back`].
fn charges(&self) -> Charges {
Charges {
objects: self.objects,
symbol_nodes: self.symbol_nodes,
kept_objects: self.definition_objects,
kept_symbol_nodes: self.definition_symbol_nodes,
left_out: self.left_out,
}
}
/// Whether art was left out for [`Self::max_objects`] since `c`.
fn left_out_since(&self, c: Charges) -> bool {
self.left_out != c.left_out
}
/// A definition that started at `c` is written: what was charged since stays charged when the
/// art that first used it is given back ([`Self::give_back`]).
fn keep_since(&mut self, c: Charges) {
self.definition_objects = c.kept_objects + (self.objects - c.objects);
self.definition_symbol_nodes = c.kept_symbol_nodes + (self.symbol_nodes - c.symbol_nodes);
}
/// Art written since `c` as `lines` is not used: the objects and symbol art charged since are
/// given back, except those of the definitions written since, and the images `lines` marks
/// are emptied (the others keep their indices).
fn give_back(&mut self, c: Charges, lines: &str) {
self.objects = c.objects + (self.definition_objects - c.kept_objects);
self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes);
for index in lines.split(PAYLOAD).skip(1).step_by(2) {
if let Some(p) = index.parse::<usize>().ok().and_then(|i| self.payloads.get_mut(i)) {
*p = Vec::new();
}
}
}
@@ -510,19 +612,22 @@ impl<'a> Writer<'a> {
NodeKind::Layer { .. } => {}
NodeKind::Group { children, clip: true } => self.clip_group(children, xy),
NodeKind::Group { children, clip: false } => {
self.line("u");
for c in children {
self.node(c);
if self.open_group("u") {
for c in children {
self.node(c);
}
self.end_group("U", xy);
}
self.end_group("U", xy);
}
NodeKind::Path { path, rule, guide, .. } => {
if *guide {
// Its own transparency, not the one the object before it set.
if path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) {
// A path for each subpath with points, with the guide's own transparency, not
// the one the object before it set.
let subpaths = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count();
if subpaths > 0 && self.take_objects(subpaths) {
self.set_object_xy(xy.object());
ai_ops::guide(&mut self.body, self.to_art, path, true);
}
ai_ops::guide(&mut self.body, self.to_art, path, true);
} else {
self.shape(n, path, *rule, xy);
}
@@ -530,8 +635,11 @@ impl<'a> Writer<'a> {
NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy),
NodeKind::Text(_) => self.text(n, xy),
NodeKind::Image(im) => self.image(im, xy),
NodeKind::SymbolInstance { symbol, xf } if self.live_instance(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy),
NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy),
NodeKind::SymbolInstance { symbol, xf } => {
if !self.open_group("u") {
return;
}
// The canvas places an instance inside a symbol's art where the outer instance puts
// it (9-slice guides, stains and patterns included), and this art doesn't move with it.
self.wrote_fixed |= self.in_symbol;
@@ -541,7 +649,6 @@ impl<'a> Writer<'a> {
} else {
self.warn(symbols::MISSING_SYMBOL);
}
self.line("u");
if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
{
@@ -583,8 +690,10 @@ impl<'a> Writer<'a> {
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. } => {
if !self.open_group("u") {
return;
}
self.warn(LIVE);
self.line("u");
if self.nest < MAX_NEST {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
self.nest += 1;
@@ -629,11 +738,14 @@ impl<'a> Writer<'a> {
if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() {
return;
}
let start = self.body.len();
let has_id = name.is_some() || !bound.is_empty();
let has_entries = entries.is_some();
self.body.push_str("%_/ArtDictionary :\n");
if let Some(e) = entries {
self.body.push_str(&e);
}
if name.is_some() || !bound.is_empty() {
if has_id {
let id = self.take_id(name.unwrap_or("XMLID"));
let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes()));
if name.is_none() {
@@ -644,8 +756,13 @@ impl<'a> Writer<'a> {
self.bound.push((n.id, id, bound));
}
}
if let Some(m) = mask {
self.mask(m);
// A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too.
if let Some(m) = mask
&& !self.mask(m)
&& !has_id
&& !has_entries
{
return self.body.truncate(start);
}
self.body.push_str("%_;\n%_\n");
}
@@ -673,10 +790,13 @@ impl<'a> Writer<'a> {
/// An opacity mask, as an entry of its object's dictionary: `/Mask :`, `X=`, its art on `%_`
/// lines (from the state a dictionary's art starts in: unlocked, shown, opaque), `X+`, `(Art) ,`,
/// its options as the `/Bool` entries `Clipping`, `Inverted`, `Disabled` and `Linked`, and
/// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is.
fn mask(&mut self, m: &OpacityMask) {
/// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is. False, writing
/// nothing, when some of the art is left out for the object limit ([`Self::take_objects`]): a
/// mask without all its art would change what it shows.
fn mask(&mut self, m: &OpacityMask) -> bool {
let outer = std::mem::take(&mut self.body);
let (state, clip) = (self.state, self.clip.take());
let (state, gs, clip) = (self.state, self.gs, self.clip.take());
let (charges, bound) = (self.charges(), self.bound.len());
(self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE));
// The canvas draws the art's root even when it is hidden, and the reader puts a hidden object
// in a group that doesn't draw it, so a hidden root is written shown.
@@ -709,6 +829,13 @@ impl<'a> Writer<'a> {
self.hidden -= 1;
self.nest -= 1;
let lines = std::mem::replace(&mut self.body, outer);
if self.left_out_since(charges) {
// The art isn't written, nor are the variable bindings of its objects.
self.give_back(charges, &lines);
self.bound.truncate(bound);
(self.state, self.gs, self.clip, self.block_end) = (state, gs, clip, usize::MAX);
return false;
}
self.body.push_str("%_/Mask :\n%_X=\n");
hide(&lines, &mut self.body);
self.body.push_str("%_X+\n%_(Art) ,\n");
@@ -718,6 +845,7 @@ impl<'a> Writer<'a> {
self.body.push_str("%_; (AI9_Mask) ,\n");
// The dictionary ends with the state before it, except the transparency its art set last.
(self.state, self.gs, self.clip, self.block_end) = (state, None, clip, usize::MAX);
true
}
/// Warn that art is cut to clipping object `first` when it isn't a path or compound path (a path
@@ -739,12 +867,16 @@ impl<'a> Writer<'a> {
if self.clip.is_some() && !children.is_empty() {
self.warn(CLIPPING);
}
self.line("u");
self.clip_contents(children);
return self.end_group("U", xy);
if self.open_group("u") {
self.clip_contents(children);
self.end_group("U", xy);
}
return;
}
let at_q = (self.state, self.gs);
self.line("q");
if !self.open_group("q") {
return;
}
self.clipped(children);
(self.state, self.gs) = at_q;
self.end_group("Q", xy);
@@ -753,7 +885,8 @@ impl<'a> Writer<'a> {
/// The members of a clipping group or layer, the first one clipping the others: the others,
/// then the first one as the clipping path (see [`Self::clip_group`]), its name after it. Fills
/// and strokes the clipping path can't paint itself are paths of their own, the fills under the
/// others and the strokes over them.
/// others and the strokes over them. Without room for the clipping path, nothing is written
/// ([`Self::take_objects`]).
fn clipped(&mut self, children: &[Arc<Node>]) {
let Some((first, rest)) = children.split_first() else { return };
let Some((path, rule)) = clip_path(first) else {
@@ -762,6 +895,12 @@ impl<'a> Writer<'a> {
};
let items = self.items(first, path.to_bezpath().bounding_box());
let painted = self.plain_paint(&items);
// The clipping path goes after the others, so its objects are counted first.
let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect();
let (fill, stroke) = painted.unwrap_or_default();
if !self.take_objects(clip_object_count(&subpaths, fill, stroke)) {
return;
}
if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) {
self.warn(ITEM_TRANSPARENCY);
}
@@ -779,12 +918,10 @@ impl<'a> Writer<'a> {
self.node(c);
}
self.clip_paint_part(first, &path, rule, &strokes);
let (fill, stroke) = painted.unwrap_or_default();
self.state(first.locked, !first.visible);
let start = self.body.len();
self.paint_state(fill, stroke, Xy::of(first).object(), rule);
let op = paint_op(fill.is_some(), stroke.is_some());
let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect();
let several = subpaths.len() > 1;
if several {
self.line("*u");
@@ -817,7 +954,9 @@ impl<'a> Writer<'a> {
if let [one] = items {
return self.item(path, rule, one, xy);
}
self.line("u");
if !self.open_group("u") {
return;
}
for i in items {
self.item(path, rule, i, Xy::PLAIN);
}
@@ -870,11 +1009,12 @@ impl<'a> Writer<'a> {
(Some((fill, stroke)), _) => self.path_object(path, rule, fill, stroke, xy),
(None, [one]) => self.item(path, rule, one, xy),
(None, items) => {
self.line("u");
for i in items {
self.item(path, rule, i, Xy::PLAIN);
if self.open_group("u") {
for i in items {
self.item(path, rule, i, Xy::PLAIN);
}
self.end_group("U", xy);
}
self.end_group("U", xy);
}
}
}
@@ -898,7 +1038,8 @@ impl<'a> Writer<'a> {
}
/// A brushed stroke `st` of `path` as a group of its brush's art, of transparency `xy`. False
/// when it has no brush the document knows (or brushes nest too deep).
/// when it has no brush the document knows (or brushes nest too deep). Without room for the
/// group ([`Self::take_objects`]), the stroke is left out.
fn brush_art(&mut self, path: &PathData, st: &StrokeLayer, xy: Xy) -> bool {
if self.nest >= MAX_NEST {
return false;
@@ -908,8 +1049,10 @@ impl<'a> Writer<'a> {
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
let art = vectorcraft_brush::stroke_pieces(doc, b, &path.to_bezpath(), st);
let state = self.state;
if !self.open_group("u") {
return true;
}
self.nest += 1;
self.line("u");
for piece in &art {
self.node(piece);
}
@@ -948,13 +1091,15 @@ impl<'a> Writer<'a> {
let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() };
return self.path_object(path, rule, Some(&flat), None, xy);
}
let state = self.state;
if !self.open_group("u") {
return;
}
self.wrote_pattern = true;
let outer = self.clip.clone();
let region = self.cut(path, rule);
self.clip = Some(region);
self.nest += 1;
let state = self.state;
self.line("u");
for t in &tiles {
self.node(t);
}
@@ -979,8 +1124,8 @@ impl<'a> Writer<'a> {
}
let both = fill.is_some() && stroke.is_some();
let own = if both { Xy::OPAQUE } else { xy.object() };
if both {
self.line("u");
if both && !self.open_group("u") {
return;
}
if fill.is_some() || stroke.is_none() {
let region = self.cut(path, rule);
@@ -1026,7 +1171,7 @@ impl<'a> Writer<'a> {
/// points and painting operator (`*u` … `*U` around several).
fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>, xy: Xy) {
let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect();
if subpaths.is_empty() {
if subpaths.is_empty() || !self.take_objects(object_count(subpaths.len())) {
return;
}
self.paint_state(fill, stroke, xy, rule);
@@ -1057,18 +1202,29 @@ impl<'a> Writer<'a> {
fn text(&mut self, n: &Node, xy: Xy) {
let NodeKind::Text(t) = &n.kind else { return };
let doc = self.doc;
let placed = if self.hidden > 0 || self.clip.is_some() || self.in_symbol {
None
} else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) {
let thread = doc.text_threads.iter().find(|th| th.contains(&n.id));
// Type that can't be a stub is written as its outlines, which count their own objects.
if self.hidden > 0 || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) {
return self.outlined_text(n, xy);
}
// The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]); without room for
// them, the type is left out before its story goes into the text document.
let stub = texts::stub_objects(t);
if !self.take_objects(stub) {
return;
}
let placed = if let Some(thread) = thread {
// A thread is checked for what it loses, in every frame, when its story is written.
let placed = self.texts.thread(doc, thread, n.id, self.canvas, &|id| !cut_to_clipping_object(doc, id));
placed.map(|p| (p, self.texts.take_lost()))
} else if texts::holds(n, t) {
self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t)))
} else {
None
self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t)))
};
let Some(((story, frame), lost)) = placed else {
// The text document can't hold it: the outlines count their own objects.
self.objects -= stub;
return self.outlined_text(n, xy);
};
let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) };
self.typed = true;
if lost {
self.warn(texts::TYPE_ATTRIBUTES);
@@ -1088,8 +1244,10 @@ impl<'a> Writer<'a> {
/// graphics as their symbols' art where the layout places them, as the PDF page draws them;
/// the group has the type's transparency `xy`.
fn outlined_text(&mut self, n: &Node, xy: Xy) {
if !self.open_group("u") {
return;
}
self.warn(TYPE);
self.line("u");
let doc = self.doc;
let resolved = match &n.kind {
NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()),
@@ -1343,6 +1501,30 @@ fn paint_op(fill: bool, stroke: bool) -> char {
}
}
/// The objects the reader makes of a path object of `subpaths` subpaths with points: a path for
/// each, and a compound path holding them when there are several.
fn object_count(subpaths: usize) -> usize {
match subpaths {
0 => 0,
1 => 1,
n => n + 1,
}
}
/// The objects the reader counts for clipping path `subpaths` (those with points) painted with
/// `fill` and `stroke` ([`Writer::clipped`]). Its last subpath carries a gradient fill's or
/// stroke's gradient, so the subpaths of a filled compound clipping path with a gradient paint
/// differently, and the reader reads their fills apart from it: a compound path of a copy of each,
/// which counts the copy and its points ([`object_count`] for the clipping path itself).
fn clip_object_count(subpaths: &[&SubPath], fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) -> usize {
let own = object_count(subpaths.len());
let apart = subpaths.len() > 1 && fill.is_some() && (gradient(fill.map(|f| &f.paint)).is_some() || gradient(stroke.map(|s| &s.paint)).is_some());
if !apart {
return own;
}
subpaths.iter().fold(own.saturating_add(1), |n, sp| n.saturating_add(1).saturating_add(sp.anchors.len()))
}
/// The subpaths of compound path `n`'s members, as one path.
fn compound_path(n: &Node) -> PathData {
let mut subpaths = vec![];
@@ -107,6 +107,11 @@ impl Writer<'_> {
if self.clip.as_ref().is_some_and(|c| c.subpaths.iter().all(|sp| sp.anchors.is_empty())) {
return;
}
// The image, and where it is cut, the clipping group and its clipping path.
let clipping = self.clip.as_ref().map_or(0, |c| 1 + super::object_count(c.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()));
if !self.take_objects(1 + clipping) {
return;
}
if deep {
self.warn(IMAGE_DEPTH);
}
+11 -2
View File
@@ -381,7 +381,8 @@ impl Writer<'_> {
/// Write pattern `name`'s definition, once: `%AI3_BeginPattern: (name)`, `(name) llx lly urx
/// ury` (the tile, y up), the tile's box (an unpainted path whose dictionary marks it,
/// `AIPattern_Editor_Backing_Tile_Rect`) and the art on `%_` lines, `E`, `%AI3_EndPattern`.
/// False when it can't be ([`Self::pattern_writable`]).
/// False when it can't be ([`Self::pattern_writable`]), or when its box and art don't all fit
/// within the object limit ([`Self::take_objects`]).
fn define_pattern(&mut self, name: &str) -> bool {
if !self.pattern_writable(name) {
return false;
@@ -392,7 +393,9 @@ impl Writer<'_> {
return true;
}
let doc = self.doc;
let Some(def) = doc.pattern(name) else { return false };
// The tile's box is an object of the definition ([`Self::take_objects`]).
let (before, bound) = (self.charges(), self.bound.len());
let Some(def) = doc.pattern(name).filter(|_| self.take_objects(1)) else { return false };
self.defining.push(name.to_string());
// The art is written in the pattern's space, y up, from the state a definition starts in.
let saved = (
@@ -427,6 +430,12 @@ impl Writer<'_> {
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved;
let art = std::mem::replace(&mut self.body, art);
self.defining.pop();
if self.left_out_since(before) {
self.give_back(before, &art);
self.bound.truncate(bound);
return false;
}
self.keep_since(before);
let tile = Affine::FLIP_Y.transform_rect_bbox(def.tile);
let quoted = ps::bytes(name.as_bytes());
let mut text = format!("%AI3_BeginPattern: {quoted}\n{quoted} ");
@@ -504,7 +504,18 @@ impl Writer<'_> {
/// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or
/// compound path's points painted with nothing, or with its gradient fill when its style has
/// one; a group with its members), its name and opacity mask; then `1 (name) XW`.
/// The look's group and the object itself are counted first ([`Self::take_objects`]): without
/// room for them, nothing is written.
pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) {
let own = match &orig.kind {
NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()),
NodeKind::Compound { .. } => super::object_count(compound_path(orig).subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()),
NodeKind::Group { .. } => 1,
_ => 0,
};
if !self.take_objects(1 + own) {
return;
}
self.state(orig.locked, !orig.visible);
let xy = Xy::of(orig);
let was = self.in_look;
@@ -533,6 +544,10 @@ impl Writer<'_> {
let saved = std::mem::take(&mut self.body);
self.hidden += 1;
let fit = drawn_outline(n, orig).map(|p| p.bounding_box());
// A path's objects were kept for it above; it counts them itself.
if !matches!(orig.kind, NodeKind::Group { .. }) {
self.objects -= own;
}
match &orig.kind {
NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy),
NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy),
+23 -2
View File
@@ -183,8 +183,9 @@ impl Writer<'_> {
/// written from the top of the nesting whatever the instance that asks for it, so a definition
/// is the same wherever its first instance lies. False when it can't be: the document doesn't
/// have the symbol, it is being written (its art holds an instance of itself), it is reached
/// through more than [`MAX_NEST`] definitions being written, or the definitions written and
/// being written leave no place under [`MAX_SYMBOLS`].
/// through more than [`MAX_NEST`] definitions being written, the definitions written and
/// being written leave no place under [`MAX_SYMBOLS`], or its art doesn't all fit within the
/// object limit ([`Self::take_objects`]).
pub(super) fn define_symbol(&mut self, name: &str) -> bool {
if self.defined_symbols.contains(name) {
return true;
@@ -204,6 +205,8 @@ impl Writer<'_> {
self.warn(SYMBOL_OPTIONS);
}
self.defining_symbols.push(name.to_string());
// The objects of this definition and of those it defines first.
let before = self.charges();
let mut used = vec![];
vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| {
if let NodeKind::SymbolInstance { symbol, .. } = &m.kind {
@@ -215,9 +218,18 @@ impl Writer<'_> {
}
if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS {
self.defining_symbols.pop();
self.keep_since(before);
self.warn(SYMBOL_COUNT);
return false;
}
// The group that holds the art is an object of the definition ([`Self::take_objects`]);
// without room for it, or for all the art, there is no definition.
let (art_start, bound) = (self.charges(), self.bound.len());
if !self.take_objects(1) {
self.defining_symbols.pop();
self.keep_since(before);
return false;
}
// The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the
// document at its own size.
let origin = self.canvas_origin;
@@ -261,7 +273,9 @@ impl Writer<'_> {
let _ = write!(self.body, "U\n%_/ArtDictionary :\n{entries}%_;\n%_\n9 () XW\n");
} else {
// A group always writes its dictionary ([`Self::dictionary`]), which takes the entries.
// It counts itself, in the room kept for it above.
self.root_entries = Some((root.id, entries));
self.objects -= 1;
self.node(root);
}
self.nest = nest;
@@ -293,6 +307,13 @@ impl Writer<'_> {
self.doc = page_doc;
let art = std::mem::replace(&mut self.body, art);
self.defining_symbols.pop();
if self.left_out_since(art_start) {
self.give_back(art_start, &art);
self.bound.truncate(bound);
self.keep_since(before);
return false;
}
self.keep_since(before);
let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes()));
self.defined_symbols.insert(name.to_string());
self.symbol_defs.push(text);
@@ -1002,6 +1002,12 @@ fn clone_v(v: &V) -> V {
}
}
/// The objects the reader reads of [`stub`] for `t`: the text object, and a path for each subpath
/// of its frame's path.
pub(in crate::ai_editing) fn stub_objects(t: &TextObject) -> usize {
1 + shape(t).map_or(0, |p| p.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count())
}
/// The text object stub of story `story`, frame `frame`: `/AI11Text :`, `/FreeUndo`, `/FrameIndex`,
/// `/StoryIndex`, for area type and type on a path the frame's path (`/Art :`, `X=`, the path in
/// art space through `to_art`, `X+`, `; /ConfiningPath ,`), `/TextAntialiasing`, `;`.
+3 -1
View File
@@ -70,7 +70,9 @@ use graphics::{GState, Out};
use interp::{Fault, Interp};
use obj::PsError;
pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member};
#[cfg(test)]
pub(crate) use ai::objects_read;
pub(crate) use ai::{MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member};
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
pub(crate) use native::{MAX_TEXT, scale_document};
+3 -1
View File
@@ -40,11 +40,13 @@ pub(crate) use envelope::MAX_CHECKED;
pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType};
pub(crate) use frames::text_frames;
pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS;
#[cfg(test)]
pub(crate) use read::objects_read;
pub use read::{
BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette,
read, read_beside, variables,
};
pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member};
pub(crate) use read::{MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member};
pub(crate) use read::is_look_slot;
+9
View File
@@ -140,6 +140,7 @@ pub use document::{
pub(crate) use document::{slot_frame, slot_of};
pub(crate) use ops::read_art_style;
pub use types::IMAGE_TOO_LARGE;
pub(crate) use types::MAX_NODES;
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead};
#[cfg(test)]
pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
@@ -235,6 +236,14 @@ pub fn read(data: &[u8], large_canvas: bool) -> Result<Structure, String> {
r.finish()
}
/// How many objects [`read`] counts against [`MAX_NODES`] in the editing data `data`.
#[cfg(test)]
pub(crate) fn objects_read(data: &[u8]) -> Result<usize, String> {
let mut r = Reader::with_names(data);
r.run()?;
Ok(r.nodes)
}
/// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private
/// data): the program's header gives the author and the custom colors the editing data's header
/// doesn't.
+2 -2
View File
@@ -9,8 +9,8 @@ pub(super) const MAX_SUBREADERS: usize = 32;
/// Most operands on the stack.
pub(super) const MAX_STACK: usize = 1 << 20;
/// Most objects read.
pub(super) const MAX_NODES: usize = 4_000_000;
/// Most objects read. The `.ai` editing data writer writes no more ([`crate::import::MAX_EDITING_OBJECTS`]).
pub(crate) const MAX_NODES: usize = 4_000_000;
/// The most objects all the blends of one import may lay out to be checked (each twice) and made
/// live; past it, blends keep their drawn steps.