mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Place: Bounding Box measures only the art that shows
With crop "bounding", file.place measured the placed art by each object's visual bounds, which include the hidden members of a group. A group holding a 40 pt square and a hidden 170 x 42 pt rectangle was measured as 251 x 123 pt: at, rect, the place cursor and file.place.info all used that box. A group whose only part on the page or artboard was a hidden member was also placed, and symbol instances were measured as a 20 pt square. The art's box now follows the rule exports use (vectorcraft_render::encode::drawn_bounds, made public): hidden objects, guides and template layers do not count; the reach of effects such as a drop shadow now counts; symbol instances are measured by their art. The same rule picks the objects on the page or artboard. Hidden objects on the page or artboard are still placed, and stay hidden. Replace and the width and height that file.place answers also measure symbol instances by their art. Docs: docs/mcp.md (Place), docs/target-app-parity.md.
This commit is contained in:
@@ -68,8 +68,9 @@ pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u
|
||||
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
|
||||
let board = board.ok_or_else(|| bad(cmd, format!("page {page}: `{file}` has {n} artboard(s)")))?;
|
||||
let layers = std::mem::take(&mut l.doc.layers);
|
||||
let nodes = super::on_artboard(&layers, board);
|
||||
let frame = if bounding { nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())) } else { Some(board) };
|
||||
let symbols = l.doc.symbol_boxes();
|
||||
let nodes = super::on_artboard(&symbols, &layers, board);
|
||||
let frame = if bounding { super::shown_bounds(&symbols, &nodes) } else { Some(board) };
|
||||
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?;
|
||||
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling, SymbolBoxes};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use vectorcraft_pdf::CropTo;
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -239,6 +239,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
doc.drop_edit_modes();
|
||||
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
let symbols = doc.symbol_boxes();
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
|
||||
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
|
||||
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
|
||||
@@ -253,16 +254,28 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
|
||||
// A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard.
|
||||
let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] };
|
||||
(on_artboard(pages, board), crop.then_some(board))
|
||||
(on_artboard(&symbols, pages, board), crop.then_some(board))
|
||||
};
|
||||
let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())));
|
||||
let natural = clip.or_else(|| shown_bounds(&symbols, &nodes));
|
||||
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
||||
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
|
||||
}
|
||||
|
||||
/// The placeable art of `layers` that lies on `board`.
|
||||
fn on_artboard(layers: &[Arc<Node>], board: Rect) -> Vec<Node> {
|
||||
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
|
||||
/// The bounds of what `nodes` draw, as an export measures art
|
||||
/// ([`vectorcraft_render::encode::drawn_bounds`]): hidden objects, guides and template layers
|
||||
/// left out, symbol instances measured by their art in `symbols`.
|
||||
pub(crate) fn shown_bounds(symbols: &SymbolBoxes, nodes: &[Node]) -> Option<Rect> {
|
||||
nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, vectorcraft_render::encode::drawn_bounds(symbols, n)))
|
||||
}
|
||||
|
||||
/// The placeable art of `layers` that lies on `board`: each object whose drawn bounds
|
||||
/// ([`vectorcraft_render::encode::drawn_bounds`]) lie on it, or, for an object that draws nothing,
|
||||
/// each object whose visual bounds lie on it.
|
||||
fn on_artboard(symbols: &SymbolBoxes, layers: &[Arc<Node>], board: Rect) -> Vec<Node> {
|
||||
let on_board = |c: &&Arc<Node>| {
|
||||
let b = vectorcraft_render::encode::drawn_bounds(symbols, c).or_else(|| c.visual_bounds_with(symbols));
|
||||
b.is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()))
|
||||
};
|
||||
art_of(layer_art(layers).filter(on_board))
|
||||
}
|
||||
|
||||
@@ -376,7 +389,7 @@ fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool) -> Affine
|
||||
return im.xf * full.inverse() * Affine::translate(centre - natural.center());
|
||||
}
|
||||
}
|
||||
let c = old.geometric_bounds().map_or(natural.center(), |b| b.center());
|
||||
let c = old.geometric_bounds_with(&d.symbol_boxes()).map_or(natural.center(), |b| b.center());
|
||||
Affine::translate(c - natural.center())
|
||||
}
|
||||
|
||||
@@ -411,7 +424,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Type reflows rather than scales: its frame takes the size it is placed at.
|
||||
let is_text = matches!(loaded.art, Art::Text(_));
|
||||
if is_text {
|
||||
let size = match (old_node.and_then(Node::geometric_bounds), rect) {
|
||||
let size = match (old_node.and_then(|o| o.geometric_bounds_with(&st.doc.symbol_boxes())), rect) {
|
||||
(Some(b), _) => b.size(),
|
||||
(None, Some(r)) => r.size(),
|
||||
(None, None) => text_frame(&st.doc, at).size(),
|
||||
@@ -436,7 +449,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, format, warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings));
|
||||
let (id, size) = s.edit("Place", |d, sel| {
|
||||
let node = transformed(build(d, loaded, link), m);
|
||||
let size = node.geometric_bounds().unwrap_or_default();
|
||||
let size = node.geometric_bounds_with(&d.symbol_boxes()).unwrap_or_default();
|
||||
let (into, index) = match old {
|
||||
Some(old) => {
|
||||
let (par, idx, _) = d.position(old).ok_or(EngineError::NoNode(old))?;
|
||||
|
||||
@@ -57,6 +57,48 @@ fn serialize(s: &mut Session, format: &str) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn eps_of(s: &mut Session) -> Vec<u8> {
|
||||
let v = s.execute("document.exportEps", &json!({})).unwrap();
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `file.place.info`'s width and height for `bytes` named `name`, with `extra` params.
|
||||
fn info_size(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> (f64, f64) {
|
||||
let mut p = file(name, bytes);
|
||||
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
let r = s.execute("file.place.info", &p).unwrap();
|
||||
(r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap())
|
||||
}
|
||||
|
||||
/// The bounds of what the active document's art draws ([`vectorcraft_render::encode::art_bounds`])
|
||||
/// lie within a millionth of a point of `want`.
|
||||
fn drawn_at(s: &Session, want: Rect) -> bool {
|
||||
let b = vectorcraft_render::encode::art_bounds(&s.doc().unwrap().doc).unwrap_or_default();
|
||||
[b.x0 - want.x0, b.y0 - want.y0, b.x1 - want.x1, b.y1 - want.y1].iter().all(|d| d.abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// A 400 × 300 pt document holding one group: a 40 × 40 pt square at (20, 20) and, hidden, a
|
||||
/// 170 × 42 pt rectangle at (100, 100). Returns it with the square's visual bounds.
|
||||
fn group_with_a_hidden_member() -> (Session, Rect) {
|
||||
let mut s = session();
|
||||
let square = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
let wide = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 170, "height": 42})).unwrap()["id"].clone();
|
||||
s.execute("object.group", &json!({"ids": [square, wide]})).unwrap();
|
||||
s.execute("object.hide", &json!({"ids": [wide]})).unwrap();
|
||||
assert!(!node(&s, &wide).visible);
|
||||
let shown = node(&s, &square).visual_bounds().unwrap();
|
||||
(s, shown)
|
||||
}
|
||||
|
||||
/// How many of `n` and the objects in it are hidden.
|
||||
fn hidden_in(n: &Node) -> usize {
|
||||
let mut hidden = 0;
|
||||
n.walk(&mut |c| hidden += usize::from(!c.visible));
|
||||
hidden
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() {
|
||||
let mut s = session();
|
||||
@@ -218,6 +260,120 @@ fn pdf_pages_and_native_artboards_place_as_clipped_groups() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With crop "bounding", the art's box is the box of what shows, as an export measures it: a hidden
|
||||
/// member of a group does not count, and is still placed, hidden.
|
||||
#[test]
|
||||
fn bounding_box_placement_measures_only_the_art_that_shows() {
|
||||
let (mut src, shown) = group_with_a_hidden_member();
|
||||
let eps = eps_of(&mut src);
|
||||
let native = serialize(&mut src, "vectorcraft");
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300"><g><rect x="20" y="20" width="40" height="40"/><rect x="100" y="100" width="170" height="42" style="display:none"/></g></svg>"#;
|
||||
// The sample's group `Pair` has a hidden member 42 pt tall; what shows is 170 × 40 pt.
|
||||
let sample = vectorcraft_testkit::ai::eps(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_ps());
|
||||
let cases: [(&str, &[u8], (f64, f64)); 4] = [
|
||||
("pair.eps", &eps, (shown.width(), shown.height())),
|
||||
("pair.vectorcraft", &native, (shown.width(), shown.height())),
|
||||
("pair.svg", svg, (40.0, 40.0)),
|
||||
("art.eps", &sample, (170.0, 40.0)),
|
||||
];
|
||||
for (name, bytes, (w, h)) in cases {
|
||||
let mut s = session();
|
||||
let (iw, ih) = info_size(&mut s, name, bytes, json!({"crop": "bounding"}));
|
||||
assert!(close(iw, w) && close(ih, h), "{name}: {iw} × {ih}, not {w} × {h}");
|
||||
// `rect` fits what shows, centered.
|
||||
let r = place(&mut s, name, bytes, json!({"crop": "bounding", "rect": [100, 100, 80, 80]}));
|
||||
let k = (80.0 / w).min(80.0 / h);
|
||||
let want = Rect::from_center_size((140.0, 140.0), (w * k, h * k));
|
||||
assert!(drawn_at(&s, want), "{name}: not {want:?}");
|
||||
assert_eq!(hidden_in(&node(&s, &r["ids"][0])), 1, "{name}: the hidden member is placed, hidden");
|
||||
}
|
||||
}
|
||||
|
||||
/// With crop "bounding", an effect's reach counts in the art's box, as in an export of the art.
|
||||
#[test]
|
||||
fn bounding_box_placement_counts_a_drop_shadow() {
|
||||
let mut src = session();
|
||||
let id = src.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
let square = node(&src, &id).visual_bounds().unwrap();
|
||||
src.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 8, "y": 0, "blur": 0}})).unwrap();
|
||||
let export = vectorcraft_render::encode::art_bounds(&src.doc().unwrap().doc).unwrap();
|
||||
assert!(export.width() > square.width() + 7.0, "{export:?}");
|
||||
let eps = eps_of(&mut src);
|
||||
let native = serialize(&mut src, "vectorcraft");
|
||||
let cases: [(&str, &[u8]); 2] = [("shadow.eps", &eps), ("shadow.vectorcraft", &native)];
|
||||
for (name, bytes) in cases {
|
||||
let mut s = session();
|
||||
let (w, h) = info_size(&mut s, name, bytes, json!({"crop": "bounding"}));
|
||||
assert!(close(w, export.width()) && close(h, export.height()), "{name}: {w} × {h}, not {export:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A symbol instance in the placed art is measured by its symbol's art, for the art's box and for
|
||||
/// the size `file.place` answers.
|
||||
#[test]
|
||||
fn a_placed_symbol_instance_is_measured_by_its_art() {
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300"><defs><symbol id="s"><rect width="100" height="10"/></symbol></defs><use xlink:href="#s" x="50" y="50"/></svg>"##;
|
||||
let mut s = session();
|
||||
assert_eq!(info_size(&mut s, "bar.svg", svg, json!({"crop": "bounding"})), (100.0, 10.0));
|
||||
let r = place(&mut s, "bar.svg", svg, json!({"crop": "bounding", "rect": [100, 100, 200, 200]}));
|
||||
assert!(close(r["width"].as_f64().unwrap(), 200.0) && close(r["height"].as_f64().unwrap(), 20.0), "{r}");
|
||||
assert!(drawn_at(&s, Rect::new(100.0, 190.0, 300.0, 210.0)));
|
||||
// Replace centers an image on it and gives a text file its size.
|
||||
let r = place(&mut s, "dot.png", &png(10, 10, [0, 0, 0, 255], None), json!({"replace": true}));
|
||||
let c = node(&s, &r["ids"][0]).geometric_bounds().unwrap().center();
|
||||
assert!(close(c.x, 200.0) && close(c.y, 200.0), "{c:?}");
|
||||
let r = place(&mut s, "bar.svg", svg, json!({"crop": "bounding", "rect": [100, 100, 200, 200]}));
|
||||
assert!(close(r["width"].as_f64().unwrap(), 200.0), "{r}");
|
||||
let r = place(&mut s, "note.txt", b"Hello", json!({"replace": true}));
|
||||
let NodeKind::Text(t) = &node(&s, &r["ids"][0]).kind else { panic!("area type") };
|
||||
let frame = t.area_frame().and_then(|f| f.bounds()).unwrap();
|
||||
assert!(close(frame.width(), 200.0) && close(frame.height(), 20.0), "{frame:?}");
|
||||
}
|
||||
|
||||
/// Crop "crop" still frames the art by its artboard (an SVG by its viewBox), hidden members or not.
|
||||
#[test]
|
||||
fn crop_placement_keeps_the_artboard_with_a_hidden_member() {
|
||||
let (mut src, _) = group_with_a_hidden_member();
|
||||
let eps = eps_of(&mut src);
|
||||
let native = serialize(&mut src, "vectorcraft");
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><g><rect x="20" y="20" width="40" height="40"/><rect x="100" y="100" width="170" height="42" style="display:none"/></g></svg>"#;
|
||||
let cases: [(&str, &[u8]); 3] = [("pair.eps", &eps), ("pair.vectorcraft", &native), ("pair.svg", svg)];
|
||||
for (name, bytes) in cases {
|
||||
let mut s = session();
|
||||
assert_eq!(info_size(&mut s, name, bytes, json!({"crop": "crop"})), (400.0, 300.0), "{name}");
|
||||
let r = place(&mut s, name, bytes, json!({"crop": "crop"}));
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(400.0), Some(300.0)), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's or artboard's art is the objects whose visible art lies on it: a group whose only part
|
||||
/// on the artboard is hidden stays out, and a hidden object on the artboard is still placed, hidden.
|
||||
#[test]
|
||||
fn a_group_whose_only_part_on_the_artboard_is_hidden_is_left_out() {
|
||||
let mut src = session();
|
||||
let off = src.execute("shape.rectangle", &json!({"x": -300, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
let under = src.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 170, "height": 42})).unwrap()["id"].clone();
|
||||
src.execute("object.group", &json!({"ids": [off, under]})).unwrap();
|
||||
src.execute("object.hide", &json!({"ids": [under]})).unwrap();
|
||||
let dot = src.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
let shown = node(&src, &dot).visual_bounds().unwrap();
|
||||
let ghost = src.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone();
|
||||
src.execute("object.hide", &json!({"ids": [ghost]})).unwrap();
|
||||
let eps = eps_of(&mut src);
|
||||
let native = serialize(&mut src, "vectorcraft");
|
||||
let cases: [(&str, &[u8]); 2] = [("parts.eps", &eps), ("parts.vectorcraft", &native)];
|
||||
for (name, bytes) in cases {
|
||||
let mut s = session();
|
||||
let (w, h) = info_size(&mut s, name, bytes, json!({"crop": "bounding"}));
|
||||
assert!(close(w, shown.width()) && close(h, shown.height()), "{name}: {w} × {h}");
|
||||
let r = place(&mut s, name, bytes, json!({"crop": "bounding"}));
|
||||
let g = node(&s, &r["ids"][0]);
|
||||
let kids = g.children().unwrap();
|
||||
assert_eq!(kids.len(), 2, "{name}: the circle and the hidden square");
|
||||
assert!(kids[0].visible && !kids[1].visible, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() {
|
||||
// The file: a global "Brand" red rectangle and a "Star" symbol instance.
|
||||
|
||||
@@ -219,7 +219,11 @@ pub fn art_bounds_on(doc: &Document, rect: Rect) -> Option<Rect> {
|
||||
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, on(&symbols, l, rect)))
|
||||
}
|
||||
|
||||
fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
/// Bounds of what an export draws of object `n` ([`art_bounds`]): `None` when `n` is hidden, a
|
||||
/// guide or a template layer. The hidden objects, guides and template layers in it are left out,
|
||||
/// except in a group or layer with effects of its own that keep all its members (`without_undrawn`
|
||||
/// lists them). Symbol instances are measured by their art in `symbols`.
|
||||
pub fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
if !n.visible {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -2483,6 +2483,10 @@ artboard `page` (or, with `crop: "bounding"`, its art's bounds), linked to the f
|
||||
(see Linked images), and vectors in every output; with `link: false` (or from `dataBase64`) it is an editable copy
|
||||
of its art, as one clipped group. `at` centres it, `rect` fits it, `replace`
|
||||
swaps the selected object (keeping its place and transform), `template` puts it on a new template layer.
|
||||
The art's bounds are the bounds of the art that shows, with its strokes and effects, as an export measures them:
|
||||
hidden objects, guides and template layers do not count, and symbol instances are measured by their art. A page or artboard holds the objects
|
||||
whose shown art lies on it; a hidden object on it is still placed and stays hidden, as do the hidden members of the groups placed.
|
||||
`replace` and the `width` and `height` that `file.place` answers also measure symbol instances by their art.
|
||||
`file.place.info` describes a file without placing it and `image.info` reports a placed image's link, colour mode and
|
||||
effective ppi. `file.place.queue` loads the place cursor (the `place` tool) with several files: headless, drive it
|
||||
with `pointer_gesture` (a click places at 100% with the top-left corner there, a drag at the dragged size) and
|
||||
|
||||
@@ -706,6 +706,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked; CIRCLE/ARC fitting, CNC preset and VCarve QA remain pending.
|
||||
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
- File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests_place.rs`).
|
||||
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
|
||||
- A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests_file_info.rs`, `crates/engine/src/tests_fileinfo.rs`).
|
||||
- Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests_file_info.rs`).
|
||||
|
||||
Reference in New Issue
Block a user