Files
vectorcraft/crates/testkit/src/fixtures.rs
T
dotCooCoo 5ac7d97d85 Guides: ruler guides are document objects on their layers
Ruler guides were a list kept beside the layers. Each guide is now an
object of its layer or sublayer, always directly in it and above the
layer's art. These behave as on main:

- Guides draw on screen only. Exports (SVG, PDF, PNG, PSD), Fit All,
  Select All, the art bounds, clicks and marquees, and a layer's
  Pathfinder and Transform effects leave them out. The Layers panel
  lists them as guide rows of their layer, the highest guide.list
  index first.
- Deleting a layer deletes its guides, and merging layers keeps their
  guides in the merged layer.
- The guide commands number guides as guide.list does, in the order
  they were made.

New: each guide takes an id from the document's object ids. An object
command given a guide's id fails with an error, and node.move cannot
put a guide into a group or out of its layers. A guide made while an
opacity mask, a symbol or a pattern is edited moves to the top
visible, unlocked layer when editing ends.

File format: native files still save the guides in the document's
guides list, in every file version, so older builds read them. Each
entry may now also carry the guide's id, name, locked and hidden
flags; older builds ignore those fields. Opening a file puts each
saved guide on its layer (on the top visible, unlocked layer when the
file names none or a layer it does not have), and each saved guide
counts against the object limit for opening a file. A ruler guide node
that a file holds below a layer's art moves up to the layer's guides,
and one anywhere else (in a group or another object, beside the
layers, or in a symbol) is removed. Ruler guides read from an .ai file
become guide objects on the layer they were on, numbered in the order
the file lists them.
2026-11-11 09:52:28 -08:00

288 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sessions and documents in known states.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
use vectorcraft_engine::Session;
use vectorcraft_geom::{PathData, Rect, shapes};
/// Execute a command and panic with context on error.
#[track_caller]
pub fn exec(s: &mut Session, id: &str, params: Value) -> Value {
match s.execute(id, &params) {
Ok(v) => v,
Err(e) => panic!("`{id}` {params} failed: {e}"),
}
}
/// The `id` field of a creation command's result.
#[track_caller]
pub fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}")))
}
/// The created date (Unix seconds) of every fixture document: File Info dates come from the clock
/// otherwise, and documents made a second apart would not compare equal.
pub const CREATED: i64 = 1_767_225_600;
/// A session with one new `w`×`h` document, dated [`CREATED`].
pub fn session_with(w: f64, h: f64) -> Session {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"width": w, "height": h, "created": CREATED}));
s
}
/// A session with one new 400×300 document.
pub fn session() -> Session {
session_with(400.0, 300.0)
}
pub fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&exec(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})))
}
pub fn ellipse(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&exec(s, "shape.ellipse", json!({"x": x, "y": y, "width": w, "height": h})))
}
pub fn star(s: &mut Session, cx: f64, cy: f64, r1: f64, r2: f64) -> NodeId {
id_of(&exec(s, "shape.star", json!({"cx": cx, "cy": cy, "radius1": r1, "radius2": r2, "points": 5})))
}
pub fn select(s: &mut Session, ids: &[NodeId]) {
exec(s, "select.set", json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}));
}
/// Place `png` as `cut.png` with `params` (`file.place`'s, besides `name` and `dataBase64`), then
/// cut the placed image's pixel data short right after its first `IDAT` chunk's type, as a
/// document saved with a damaged image holds it: the header still reads, but the renderer can't
/// decode the image and draws nothing. Place refuses such a file, so tests cut a placed one. The
/// image is stored under the key of its new bytes. → `file.place`'s reply.
pub fn place_cut_png(s: &mut Session, png: &[u8], params: Value) -> Value {
let mut p = json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(png)});
if let (Some(o), Value::Object(extra)) = (p.as_object_mut(), params) {
o.extend(extra);
}
let r = exec(s, "file.place", p);
let id = NodeId(r["ids"][0].as_u64().unwrap());
let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Image(im)) = doc.node(id).map(|n| &n.kind) else { panic!("{id:?} is not an image") };
let old = im.key.clone();
let mut bytes = doc.images[&old].bytes.to_vec();
let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap();
bytes.truncate(idat + 8);
let cut = vectorcraft_doc::ImageBlob::new("image/png", bytes);
let key = cut.content_key();
doc.images.insert(key.clone(), cut);
if let Some(Node { kind: NodeKind::Image(im), .. }) = doc.node_mut(id) {
im.key = key;
}
let mut shown = false;
doc.visit_images(|_, im| shown |= im.key == old);
if !shown {
doc.images.remove(&old);
}
r
}
/// The three standard fixture states for sweeping commands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fixture {
/// A new document with nothing in it.
Empty,
/// One rectangle, selected.
Single,
/// Several overlapping objects of different kinds (paths, live shapes, text, a group), three selected.
Multi,
}
impl Fixture {
pub const ALL: [Fixture; 3] = [Fixture::Empty, Fixture::Single, Fixture::Multi];
pub fn session(self) -> Session {
let mut s = session();
match self {
Fixture::Empty => {}
Fixture::Single => {
rect(&mut s, 50.0, 50.0, 100.0, 60.0);
}
Fixture::Multi => {
let a = rect(&mut s, 40.0, 40.0, 120.0, 80.0);
let b = ellipse(&mut s, 100.0, 60.0, 90.0, 90.0);
let c = star(&mut s, 200.0, 150.0, 50.0, 25.0);
let t = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 250, "text": "Hi", "size": 24})));
let p = id_of(&exec(
&mut s,
"path.create",
json!({"anchors": [{"x": 250, "y": 30}, {"x": 330, "y": 60, "in": [300, 20], "out": [360, 100]}, {"x": 300, "y": 140}], "closed": false}),
));
let g1 = rect(&mut s, 300.0, 200.0, 40.0, 40.0);
let g2 = ellipse(&mut s, 320.0, 220.0, 40.0, 40.0);
select(&mut s, &[g1, g2]);
exec(&mut s, "object.group", json!({}));
let _ = (t, p);
select(&mut s, &[a, b, c]);
}
}
s
}
}
/// A document touching most node kinds and appearance features, built through commands (so its
/// history is non-trivial). Selection is left empty.
pub fn rich_session() -> Session {
let mut s = session_with(500.0, 400.0);
let a = rect(&mut s, 20.0, 20.0, 150.0, 100.0);
exec(
&mut s,
"paint.setFill",
json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "angle": 30}}),
);
let b = ellipse(&mut s, 100.0, 60.0, 120.0, 120.0);
exec(&mut s, "transparency.set", json!({"opacity": 60, "blend": "Multiply"}));
exec(&mut s, "stroke.set", json!({"weight": 4, "dash": [6, 3], "cap": "round"}));
let c = star(&mut s, 300.0, 100.0, 60.0, 30.0);
exec(&mut s, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 3, "detail": 5, "seed": 7}}));
let l = id_of(&exec(&mut s, "shape.line", json!({"x1": 20, "y1": 300, "x2": 200, "y2": 350})));
exec(&mut s, "stroke.set", json!({"weight": 3, "endArrow": "Arrow"}));
let tx = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "VectorCraft", "size": 30})));
// A non-default paragraph composer, so format round trips cover it.
exec(&mut s, "text.setFormat", json!({"ids": [tx.0], "composer": "singleLine"}));
// Area type with non-default Area Type Options (vertical alignment, inset).
let area = id_of(&exec(
&mut s,
"text.create",
json!({"x": 250, "y": 340, "text": "Centred area type", "size": 12, "area": {"width": 120, "height": 50}}),
));
select(&mut s, &[area]);
exec(&mut s, "text.areaOptions", json!({"verticalAlign": "center", "inset": 2}));
let _ = id_of(&exec(
&mut s,
"text.create",
json!({"x": 300, "y": 330, "size": 14, "text": "Area type shrinks its text to fit the frame.",
"area": {"width": 150, "height": 40, "fit": "shrinkText", "fitMinPercent": 40}}),
));
// Area type whose paragraphs differ (alignment, spacing, tabs).
let para = id_of(&exec(
&mut s,
"text.create",
json!({"x": 20, "y": 360, "text": "Heading\nBody one\tx\nBody two", "size": 9, "area": {"width": 160, "height": 36}}),
));
exec(&mut s, "text.setStyle", json!({"id": para, "justify": "center", "start": 0, "end": 0}));
exec(&mut s, "text.setFormat", json!({"ids": [para], "spaceBefore": 3, "firstLineIndent": 6, "start": 9, "end": 9}));
exec(&mut s, "text.tabs.set", json!({"ids": [para], "stops": [{"position": 80, "align": "right"}], "start": 9, "end": 9}));
exec(&mut s, "layer.new", json!({"name": "Top"}));
let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0);
let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0);
select(&mut s, &[d, e]);
exec(&mut s, "object.compoundPath.make", json!({}));
let clip = ellipse(&mut s, 30.0, 150.0, 80.0, 80.0);
let inner = rect(&mut s, 10.0, 170.0, 140.0, 30.0);
exec(&mut s, "paint.setFill", json!({"color": "#22aa44"}));
select(&mut s, &[inner, clip]);
exec(&mut s, "object.clippingMask.make", json!({}));
// Type with an inline graphic: a symbol (a small circle, drawn here) set in a line of text.
let dot = ellipse(&mut s, 440.0, 40.0, 12.0, 12.0);
select(&mut s, &[dot]);
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
let t = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 370, "text": "Tap to add", "size": 18})));
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 4, "symbol": "Dot"}));
// Variables (data merge): both kinds, bound to art, with two dataset rows and one applied,
// so format round trips cover the definitions, the bindings and the rows.
exec(&mut s, "variable.define", json!({"name": "Title", "kind": "text"}));
exec(&mut s, "variable.define", json!({"name": "Badge", "kind": "visibility"}));
exec(&mut s, "variable.bind", json!({"variable": "Title", "ids": [tx.0]}));
exec(&mut s, "variable.bind", json!({"variable": "Badge", "ids": [a]}));
exec(&mut s, "dataset.new", json!({"name": "Row 1", "values": {"Title": "Merged", "Badge": true}}));
exec(&mut s, "dataset.new", json!({"name": "Row 2", "values": {"Title": "Other"}}));
exec(&mut s, "dataset.select", json!({"name": "Row 1"}));
let _ = (a, b, c, l);
exec(&mut s, "select.none", json!({}));
s
}
/// Build documents directly (without the engine), e.g. for render tests.
pub struct DocBuilder {
pub doc: Document,
pub layer: NodeId,
}
impl DocBuilder {
pub fn new(w: f64, h: f64) -> Self {
let doc = Document::new(w, h);
let layer = doc.layers[0].id;
Self { doc, layer }
}
pub fn alloc(&mut self) -> NodeId {
self.doc.alloc_id()
}
/// A path node (not inserted).
pub fn path_node(&mut self, path: PathData, appearance: Appearance) -> Node {
let id = self.alloc();
Node::path(id, path, appearance)
}
/// A filled, unstroked rectangle node (not inserted).
pub fn rect_node(&mut self, r: Rect, fill: Color) -> Node {
self.path_node(shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0))
}
/// Append `node` at the top of the current layer.
pub fn add(&mut self, node: Node) -> NodeId {
let l = self.layer;
self.doc.insert(Some(l), usize::MAX, node).expect("insert")
}
/// Add a filled rectangle; `f` may tweak the node first.
pub fn rect(&mut self, r: Rect, fill: Color, f: impl FnOnce(&mut Node)) -> NodeId {
let mut n = self.rect_node(r, fill);
f(&mut n);
self.add(n)
}
/// Add any path with an appearance; `f` may tweak the node first.
pub fn path(&mut self, path: PathData, appearance: Appearance, f: impl FnOnce(&mut Node)) -> NodeId {
let mut n = self.path_node(path, appearance);
f(&mut n);
self.add(n)
}
/// Add a group of `children` (bottom first). With `clip`, the first child is the clipping path.
pub fn group(&mut self, mut children: Vec<Node>, clip: bool) -> NodeId {
if clip && let Some(NodeKind::Path { clipping, .. }) = children.first_mut().map(|n| &mut n.kind) {
*clipping = true;
}
let id = self.alloc();
let mut g = Node::group(id, children.into_iter().map(Arc::new).collect());
if let NodeKind::Group { clip: c, .. } = &mut g.kind {
*c = clip;
}
self.add(g)
}
/// Add a new layer and make it current.
pub fn layer(&mut self, name: &str) -> NodeId {
self.layer = self.doc.add_layer(Some(name));
self.layer
}
pub fn build(self) -> Document {
self.doc
}
}
/// All nodes of a document other than layers and ruler guides, in paint order.
pub fn art_nodes(doc: &Document) -> Vec<&Node> {
let mut v = Vec::new();
doc.walk(|n| {
if !n.is_layer() && !n.is_ruler_guide() {
v.push(n)
}
});
v
}
/// Ids of the top-level art (direct children of layers other than ruler guides), bottom first.
pub fn top_level_ids(doc: &Document) -> Vec<NodeId> {
doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).map(|n| n.id)).collect()
}
/// Every node id (layers included), paint order.
pub fn all_ids(doc: &Document) -> Vec<NodeId> {
let mut v = Vec::new();
doc.walk(|n| v.push(n.id));
v
}