diff --git a/Cargo.lock b/Cargo.lock index 267130fc41..f8dffe6f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6632,6 +6632,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "vectorcraft-brush", "vectorcraft-doc", "vectorcraft-geom 0.24.1", "vectorcraft-testkit", diff --git a/crates/brush/src/stored_art.rs b/crates/brush/src/stored_art.rs index a386e2e181..e54fdd3121 100644 --- a/crates/brush/src/stored_art.rs +++ b/crates/brush/src/stored_art.rs @@ -120,6 +120,13 @@ impl StoredArt { doc.number_mounted_art(); } + /// Sets the art on `doc` as [`Document::mounted_art`] with the ids it is stored with, the id + /// counter left as it is: what a save looks through, writing back only the brushes whose art it + /// changed ([`Self::write_back`]). + pub fn mount_as_stored(&self, doc: &mut Document) { + doc.mounted_art = self.entries.iter().flat_map(|e| e.art.iter().cloned()).collect(); + } + /// Takes [`Document::mounted_art`] off `doc` and writes each brush whose art changed (new ids /// included) back into its library entry: the entry at its index when that entry is as it was /// read or reads as the brush as it was apart from its name, else the first entry that reads as diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 382f06bc5f..cbf46c97c0 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -236,15 +236,13 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp } /// `doc` with its path geometry rounded to `decimals` places, as [`vectorcraft_format::save_to`] -/// writes it for `version`: placed documents become groups of their art for older versions. +/// writes it for `version`: placed documents, those in the stored brushes' art included, become +/// groups of their art for older versions. fn rounded(doc: &Document, version: u32, decimals: u8) -> Document { - let mut d = if version < vectorcraft_format::VERSION && doc.has_placed() { - let mut d = doc.with_placed_art().into_owned(); - d.placed_as_groups(); - d - } else { - doc.clone() - }; + let mut d = doc.clone(); + if version < vectorcraft_format::VERSION { + vectorcraft_format::placed_as_groups(&mut d); + } d.round_geometry(decimals.min(vectorcraft_format::MAX_PRECISION)); d } diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index e5107c247b..193e86b600 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -999,8 +999,9 @@ impl RecoveryJob { pub fn write(&self) -> std::result::Result { let store = self.store.as_ref(); let doc = self.save.doc(); - // A linked image keeps its preview, as a save writes it. - let linked = doc.linked_only_images(); + // A linked image keeps its preview, as a save writes it (the images in the stored brushes' + // art counted too). + let linked = super::links::with_brush_art(doc).linked_only_images(); let mut lean = Document::clone(doc); let blobs = std::mem::take(&mut lean.images); let enc = self.save.encode_doc(&lean).map_err(|e| e.to_string())?; diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 662e301caa..64926b7cfb 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1023,6 +1023,78 @@ fn saved_for_older_apps_with_a_precision_the_placed_art_is_rounded_too() { assert_ne!(preview, crate::cmd::fileio::preview_png(&s.doc().unwrap().doc).unwrap()); } +/// Saved for an older version, a placed document held only in stored brushes' art (a scatter +/// brush's art and a pattern brush's tile) is saved as the art it shows, with the resources that art +/// uses, and a precision save's preview shows that art as the file holds it, rounded. The current +/// version keeps it placed. +#[test] +fn saved_for_older_apps_placed_documents_in_brush_art_become_their_art() { + let dir = Folder::new("placed-brush-older"); + let src = dir.file("badge.vectorcraft"); + // A symbol `Star` and a triangle off the whole-point grid. + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let star = Node::path(vectorcraft_doc::NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look(BLUE)); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Star".into(), art: std::sync::Arc::new(star) }); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Star".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let points = [(3.4876474632797176, 10.5), (96.5, 2.4876), (40.4876, 97.5)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::path::SubPath::polyline(&points, true)); + let id = d.alloc_id(); + d.insert(Some(layer), 1, Node::path(id, path, look(RED))).unwrap(); + write(&src, &vectorcraft_format::save_file(&d)); + let mut s = session(); + let id = place(&mut s, &src); + let art = s.doc().unwrap().doc.node(id).unwrap().clone(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let scatter = vectorcraft_brush::Scatter { art: art.clone(), ..Default::default() }; + let pattern = vectorcraft_brush::PatternBrush { side: art, ..Default::default() }; + vectorcraft_brush::store( + d, + &[ + vectorcraft_brush::Brush { name: "Badge Dots".into(), kind: vectorcraft_brush::BrushKind::Scatter(scatter) }, + vectorcraft_brush::Brush { name: "Badge Band".into(), kind: vectorcraft_brush::BrushKind::Pattern(pattern) }, + ], + ); + assert!(!d.has_placed(), "only the brushes hold it"); + } + let line = s.execute("shape.line", &json!({"x1": 20, "y1": 150, "x2": 380, "y2": 150})).unwrap()["id"].clone(); + s.execute("brush.apply", &json!({"name": "Badge Dots", "ids": [line]})).unwrap(); + // The placed documents in each brush's art. + let placed_in_brushes = |d: &Document| { + let mut n = 0; + for b in vectorcraft_brush::library(d) { + for art in b.art() { + art.walk(&mut |c| n += usize::from(matches!(c.kind, NodeKind::PlacedDocument(_)))); + } + } + n + }; + let prefix = vectorcraft_doc::placed_document::RESOURCE_PREFIX; + let older = vectorcraft_format::VERSION - 1; + let old = + vectorcraft_format::load(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "version": older})).unwrap())).unwrap(); + assert_eq!(placed_in_brushes(&old), 0, "{}", old.unknown[vectorcraft_brush::DOC_KEY]); + assert_eq!(vectorcraft_brush::library(&old).len(), 2); + for b in vectorcraft_brush::library(&old) { + assert!(matches!(b.art()[0].kind, NodeKind::Group { .. }), "{}: {}", b.name, b.art()[0].kind_label()); + } + assert!(old.symbols.iter().any(|s| s.name.starts_with(prefix)), "the art's symbol is saved with it"); + let current = vectorcraft_format::load(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap())).unwrap(); + assert_eq!(placed_in_brushes(¤t), 2, "the current version keeps them placed"); + assert!(current.symbols.iter().all(|s| !s.name.starts_with(prefix))); + // With a precision, the preview shows the brushes' art as the file holds it, rounded. + let file = decode(&s.execute("file.saveCopy", &json!({"version": older, "precision": 0, "preview": true})).unwrap()); + let saved = vectorcraft_format::load(&file).unwrap(); + assert_eq!(placed_in_brushes(&saved), 0); + let preview = vectorcraft_format::preview(&file); + assert!(preview.is_some()); + assert_eq!(preview, crate::cmd::fileio::preview_png(&saved).unwrap()); + assert_ne!(preview, crate::cmd::fileio::preview_png(&s.doc().unwrap().doc).unwrap()); +} + #[test] fn break_link_gives_the_editable_copy_placing_without_link_gives() { let dir = Folder::new("placed-break"); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index a51f73b222..eed8801efc 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1532,6 +1532,47 @@ fn images_sharing_an_entry_are_written_once() { assert_eq!(store.images_written().len(), 1, "restored into this session's area once"); } +/// A linked image shown only in a stored brush's art is kept as its preview, as a save writes it; +/// an image key that a brush's art embeds keeps its full bytes though the layers show it linked. +#[test] +fn recovery_brush_links_keep_proxy_and_embedded_full_bytes() { + let (full, proxy) = (vec![1u8; 40], vec![2u8; 10]); + for brush_linked in [true, false] { + let store = Arc::new(MemoryStore::default()); + let mut s = session_in(&store); + { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let blob = + vectorcraft_doc::ImageBlob { proxy: Some(Arc::new(proxy.clone())), ..vectorcraft_doc::ImageBlob::new("image/jpeg", full.clone()) }; + d.images.insert("k".into(), blob); + let image = |link: Option| { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::IDENTITY, + link, + placement: Default::default(), + pixels: None, + }; + vectorcraft_doc::Node::new(NodeId(0), vectorcraft_doc::NodeKind::Image(Box::new(im))) + }; + let link = Some(vectorcraft_doc::LinkInfo::new("/work/k.jpg")); + if !brush_linked { + let (mut n, layer) = (image(link.clone()), d.layers[0].id); + n.id = d.alloc_id(); + d.insert(Some(layer), 0, n).unwrap(); + } + let art = image(if brush_linked { link } else { None }); + let scatter = vectorcraft_brush::Scatter { art, ..Default::default() }; + vectorcraft_brush::store(d, &[vectorcraft_brush::Brush { name: "Dots".into(), kind: vectorcraft_brush::BrushKind::Scatter(scatter) }]); + } + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let want = if brush_linked { proxy.len() } else { full.len() }; + assert_eq!(r["saved"][0]["imageBytes"], want, "linked in the brush: {brush_linked}: {r}"); + } +} + #[test] fn copies_from_before_images_were_kept_apart_still_restore() { let store = Arc::new(MemoryStore::default()); diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index fc6a060a5a..bd004d2d1e 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] vectorcraft-doc = { workspace = true } +vectorcraft-brush = { workspace = true } craft-vectorcraft-format = { workspace = true } serde ={ workspace = true } serde_json = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 968cd720a0..9add0798c7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -52,6 +52,7 @@ use std::sync::Arc; use craft_vectorcraft_format::{Contents, Head, Image, ReadError, Tail}; use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}; use serde::{Deserializer, Serialize}; +use vectorcraft_brush::stored_art::StoredArt; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] @@ -243,18 +244,20 @@ fn body(doc: &Document, o: &SaveOptions) -> Result { // version reads them. d.guides_to_saved(); // Older apps don't know placed documents: they get the art the objects show, with its - // resources. Otherwise resources only exporting adds are never saved. - if version < VERSION && d.has_placed() { - d = d.with_placed_art().into_owned(); - d.placed_as_groups(); - } else { - d.drop_placed_resources(); - } + // resources. Otherwise resources only exporting adds are never saved. The images and placed + // documents in the stored brushes' art count as those in the layers do. + let linked = with_brush_art(&mut d, |d| { + if version < VERSION { + placed_as_groups(d); + } else { + d.drop_placed_resources(); + } + d.linked_only_images() + }); // The art placed documents became for older versions is rounded too. if let Some(decimals) = o.precision { d.round_geometry(decimals.min(MAX_PRECISION)); } - let linked = d.linked_only_images(); // The blobs go in the file's `images` (only those the document uses). let blobs = std::mem::take(&mut d.images); if version < 3 { @@ -277,6 +280,39 @@ fn body(doc: &Document, o: &SaveOptions) -> Result { Ok(Body { d, v1, blobs, linked }) } +/// `f` run on `d` with the art of its stored brushes set on it with the ids it is stored with +/// ([`StoredArt::mount_as_stored`]); the brushes whose art `f` changed are then written back into +/// the library, and the art is taken off `d` again. `f` runs on `d` as it is when no stored brush's +/// art holds an image or a placed document, or when art is set on `d` already. +pub fn with_brush_art(d: &mut Document, f: impl FnOnce(&mut Document) -> T) -> T { + if !d.mounted_art.is_empty() { + return f(d); + } + let art = StoredArt::read(d); + if art.is_empty() { + return f(d); + } + art.mount_as_stored(d); + let out = f(d); + art.write_back(d); + out +} + +/// Every placed document in `d`'s art, the art of its stored brushes included, replaced by a group +/// of the art it shows, with the resources that art uses, as a save for a version older than +/// [`VERSION`] writes it. Without a placed document, the resources only exporting adds are dropped +/// ([`Document::drop_placed_resources`]). +pub fn placed_as_groups(d: &mut Document) { + with_brush_art(d, |d| { + if d.has_placed() { + *d = d.with_placed_art().into_owned(); + d.placed_as_groups(); + } else { + d.drop_placed_resources(); + } + }); +} + /// Serialize a document (pretty = human-diffable) in the current version. Editing-mode working /// copies are left out ([`Document::without_edit_modes`]). pub fn save(doc: &Document, pretty: bool) -> Vec { diff --git a/crates/format/tests/it/links.rs b/crates/format/tests/it/links.rs index e5a789f0d1..557218a706 100644 --- a/crates/format/tests/it/links.rs +++ b/crates/format/tests/it/links.rs @@ -4,8 +4,9 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::{Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind}; -use vectorcraft_format::{base64_decode, load, save}; +use vectorcraft_brush::{Brush, BrushKind, PatternBrush, Scatter}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; +use vectorcraft_format::{SaveOptions, base64_decode, load, save, save_with}; use vectorcraft_geom::Affine; fn image(d: &mut Document, key: &str, link: Option) { @@ -59,3 +60,94 @@ fn link_details_round_trip_and_linked_only_images_save_their_preview() { let again: Value = serde_json::from_slice(&save(&back, false)).unwrap(); assert_eq!(again["images"]["linked"], v["images"]["linked"]); } + +/// An image object for a stored brush's art. +fn brush_image(key: &str, link: Option) -> Node { + let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default(), pixels: None }; + Node::new(NodeId(0), NodeKind::Image(Box::new(im))) +} + +/// Stores a scatter brush with `scatter` as its art and a pattern brush with `tile` as its side tile. +fn brushes(d: &mut Document, scatter: Node, tile: Node) { + vectorcraft_brush::store( + d, + &[ + Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: scatter, ..Default::default() }) }, + Brush { name: "Frame".into(), kind: BrushKind::Pattern(PatternBrush { side: tile, ..Default::default() }) }, + ], + ); +} + +/// `key`'s entry in the saved file `v`: its bytes and its `proxy` flag. +fn saved_image(v: &Value, key: &str) -> (Vec, Value) { + (base64_decode(v["images"][key]["data"].as_str().unwrap()).unwrap(), v["images"][key]["proxy"].clone()) +} + +/// A linked image shown only in a stored brush's art (a scatter brush's art, a pattern brush's tile) +/// is saved as its preview, as one in the layers is, and with Include Linked Files as its file's +/// bytes; the brushes are saved as they were, their links kept. +#[test] +fn brush_only_link_saves_preview_unless_included() { + let mut d = Document::new(100.0, 100.0); + d.images.insert("dot".into(), blob(&[1, 2, 3], Some(&[9]))); + d.images.insert("tile".into(), blob(&[4, 5, 6], Some(&[8]))); + brushes(&mut d, brush_image("dot", Some(LinkInfo::new("/work/dot.jpg"))), brush_image("tile", Some(LinkInfo::new("/work/tile.jpg")))); + let stored = d.unknown[vectorcraft_brush::DOC_KEY].clone(); + + let bytes = save(&d, false); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(saved_image(&v, "dot"), (vec![9], Value::Bool(true))); + assert_eq!(saved_image(&v, "tile"), (vec![8], Value::Bool(true))); + let back = load(&bytes).unwrap(); + assert_eq!(back.unknown[vectorcraft_brush::DOC_KEY], stored, "the brushes are saved as they were"); + assert!(back.mounted_art.is_empty()); + let mut links = vec![]; + for b in vectorcraft_brush::library(&back) { + for art in b.art() { + art.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + links.extend(im.link.as_ref().map(|l| l.path.clone())); + } + }); + } + } + links.sort(); + assert_eq!(links, ["/work/dot.jpg", "/work/tile.jpg"]); + + let included = save_with(&d, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap(); + let v: Value = serde_json::from_slice(&included).unwrap(); + assert_eq!(saved_image(&v, "dot"), (vec![1, 2, 3], Value::Null)); + assert_eq!(saved_image(&v, "tile"), (vec![4, 5, 6], Value::Null)); +} + +/// An image key shown linked in one place and embedded in another keeps its full bytes, whichever +/// of the layers and a stored brush's art embeds it; the links stay as they were. +#[test] +fn shared_brush_image_key_keeps_full_bytes_for_embedded_use() { + for layer_linked in [true, false] { + let mut d = Document::new(100.0, 100.0); + d.images.insert("k".into(), blob(&[1, 2, 3], Some(&[9]))); + let link = Some(LinkInfo::new("/work/k.jpg")); + let (in_layer, in_brush) = if layer_linked { (link, None) } else { (None, link) }; + image(&mut d, "k", in_layer.clone()); + brushes(&mut d, brush_image("k", in_brush.clone()), brush_image("k", in_brush.clone())); + let bytes = save(&d, false); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(saved_image(&v, "k"), (vec![1, 2, 3], Value::Null), "linked in the layer: {layer_linked}"); + let back = load(&bytes).unwrap(); + let mut layer_links = vec![]; + for l in &back.layers { + l.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + layer_links.push(im.link.clone()); + } + }); + } + assert_eq!(layer_links, [in_layer]); + let brush_links: Vec> = vectorcraft_brush::library(&back) + .iter() + .flat_map(|b| b.art().into_iter().map(|n| if let NodeKind::Image(im) = &n.kind { im.link.clone() } else { None }).collect::>()) + .collect(); + assert_eq!(brush_links, [in_brush.clone(), in_brush]); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 73f5f1e959..a302b3afc7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8234,7 +8234,10 @@ or in `options` (`file.formatOptions {format}` lists them with their values): - `separateArtboards: true` also writes each artboard of `range` (`"1-3, 5"`, default `"all"`) to `-.` beside the file: that artboard and the art touching it. The result's `files` lists every file written, the master file first (without a path: `files: [{name, dataBase64}]`). 3 artboards give 4 files. -- `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews. +- `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews. Linked + images in the art of the stored brushes count as those in the layers do, and an image key that the layers or a + brush's art shows embedded keeps its pixels either way. A `version` older than the current one saves the placed + documents in a brush's art as groups of the art they show. - `embedProfiles` (default true) carries the ICC profiles the document is tagged with that were loaded from files; opening the file installs them where they are missing (a warning names one that can't be used). - `pdfCompatible`: native files (default true) also carry a PDF of every artboard (`pdf` in the file). This makes saves diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 100580765c..a8b6d70c9b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1943,6 +1943,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the web, when a document opens with linked files that can't be read and linked files that changed on disk (Update Links: Manually), the status bar shows the line about the unreadable files, and the line about the changed files is the first of the notes its hover lists, ahead of the open's notes. With Ask When Modified the update question opens and the line about the unreadable files stays (`a_web_open_with_unreadable_and_modified_links_keeps_both_lines`). - Linked images and placed documents kept only in symbols, graph designs, patterns or opacity masks are listed in the Links panel after the layers' own, one row per stored object, with their status in its Missing and Modified filters, in Check Links, in the missing and modified files reported when a document opens, in the link watch and in Find in Folder; Relink, Update, Embed, Placement Options and Link Info act on them, and Go To says they have no place on the canvas. Opening a document gives such an object whose id is 0 or repeats another object's a new id. - Linked images and placed documents in the art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) get Links panel rows of their own the same way, one per linked or placed object in each brush tile, naming the brush; Relink, Update, Embed, Placement Options, Link Info and Go To act on that art and write it back into the brush library, and the ids they are listed by are kept there. Evidence: `links_in_stored_brush_art_are_listed_and_checked_with_ids`, `links_commands_act_on_images_in_stored_brush_art_by_id`, `links_in_stored_brush_art_open_by_id_and_unreadable_entries_stay`. + - Saving a `.vectorcraft` file reads the images and placed documents in the art of the stored brushes as it reads those in the layers. A linked image shown only in a brush's art is saved as its preview, or with Include Linked Files as its file's bytes, and an image key that the layers or a brush's art shows embedded keeps its full bytes. Saving for an older version replaces the placed documents in a brush's art with groups of the art they show, with that art's resources, before a precision rounds it, so the preview and PDF match the file. Data Recovery copies keep the same images. These are VectorCraft's own rules for its native format; the [help page](https://helpx.adobe.com/illustrator/using/saving-artwork.html) describes Include Linked Files as embedding the files linked to the artwork. Evidence: `brush_only_link_saves_preview_unless_included`, `shared_brush_image_key_keeps_full_bytes_for_embedded_use` (vectorcraft-format), `saved_for_older_apps_placed_documents_in_brush_art_become_their_art`, `recovery_brush_links_keep_proxy_and_embedded_full_bytes` (vectorcraft-engine). - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). - A look at the linked files whose worker thread ends without a result, such as after a panic, no longer stops later looks: the app clears it, stops asking for a frame every 100 ms, and the next look starts on schedule (`crates/engine/src/tests/linkwatch.rs`, `crates/ui-egui/src/tests/place.rs`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499).