Merge pull request #6220 from dotCooCoo/fix/ai-writer-object-limit-2

.ai save: the editing copy stops at the reader's object limit
This commit is contained in:
BFlat
2026-11-09 09:28:24 +08:00
committed by GitHub
12 changed files with 484 additions and 52 deletions
+8 -1
View File
@@ -169,7 +169,8 @@
//! 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 the reader wouldn't read, links to
//! graphic styles on objects not written with them, and every art style on a large canvas, are left
//! out.
//! 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`]).
@@ -242,6 +243,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());
@@ -283,6 +289,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;
+172
View File
@@ -833,6 +833,178 @@ 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 read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap();
let (all, limited) = text(read);
assert!(!limited && all.contains("(AI9_Mask)"), "{all}");
let has_mask = |max: usize| text(max).0.contains("(AI9_Mask)");
let Some(fit) = (0..=read).find(|&max| has_mask(max)) else { panic!("the mask fits with room for {read} objects") };
assert!(fit > 2, "the mask needs room beyond the two squares: {fit}");
for max in fit..=read {
assert!(has_mask(max), "room for {max} of {read}, the mask fits in {fit}");
}
let (short, limited) = text(fit - 1);
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}");
assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "art is left out with room for {max} of {read} objects");
let objects = crate::import::objects_read(&out.program).unwrap();
assert!(objects <= max, "{objects} objects read with room for {max}");
}
}
/// 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");
}
/// On a canvas of the usual size, nested symbols whose instances multiply, written as their art in
/// art cut to a clipping object, stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art
/// in all, with a warning.
+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} ");
@@ -688,7 +688,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;
@@ -717,6 +728,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;
@@ -206,6 +207,8 @@ impl Writer<'_> {
// The art's effects are its own, not those of a drawn look that first places it.
let in_look = std::mem::take(&mut self.in_look);
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 {
@@ -217,10 +220,19 @@ 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);
self.in_look = in_look;
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;
@@ -264,7 +276,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;
@@ -297,6 +311,13 @@ impl Writer<'_> {
self.in_look = in_look;
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);
@@ -1016,6 +1016,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`, `;`.
+4 -1
View File
@@ -73,8 +73,11 @@ use graphics::{GState, Out};
use interp::{Fault, Interp};
use obj::PsError;
#[cfg(test)]
pub(crate) use ai::objects_read;
pub(crate) use ai::{
MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, symbol_art_member, warp_number,
MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with,
symbol_art_member, warp_number,
};
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};
+4 -1
View File
@@ -41,13 +41,16 @@ 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;
#[cfg(test)]
pub(crate) use read::with_image_limits;
pub use read::{
BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, Symbols, active_artboard, artboards, bindings_note, brushes, graphic_styles,
palette, read, read_beside, symbols, variables,
};
pub(crate) use read::{
MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, symbol_art_member,
MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with,
symbol_art_member,
};
pub(crate) use read::is_look_slot;
+9
View File
@@ -149,6 +149,7 @@ pub(crate) use document::{slot_frame, slot_of};
pub(crate) use ops::{read_art_style, read_style_list, style_moves_with};
#[cfg(test)]
pub(crate) use raster::with_image_limits;
pub(crate) use types::MAX_NODES;
pub use types::PlacedPage;
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
#[cfg(test)]
@@ -259,6 +260,14 @@ fn read_with(data: &[u8], large_canvas: bool, page_art: bool) -> Result<Structur
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. `page_art`: as [`read_over_page`] reads a file.
+2 -2
View File
@@ -11,8 +11,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.