Files
designcraft/crates/engine/src/tests.rs
T
moppeeerandClaude Opus 5.5 cc13e80c9d feat(tools): Page tool selects pages; handles preview the liquid rule, Alt-drag resizes
Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:42–
2:06) selects a page with the Page tool, gets its bounding box with
handles, drags a handle (the page springs back: a liquid-rule preview),
holds Option to really resize it, Shift for proportions, and reads the
size in a tooltip by the cursor. DesignCraft's Page tool only opened a
generic Page Size dialog on click.

- tools: the Page tool selects the page under a click (none on the
  pasteboard); the selected page shows its outline, eight handles and
  its liquid page rule ("Scale", "Object-Based"…) above it. Dragging a
  handle previews layout.pageSize (the liquid rule re-flows the
  objects) and springs back on release; Alt resizes, Shift keeps the
  proportions; a W/H readout follows the pointer; resize cursors on the
  handles. Double-click a page for the Page Size dialog.
- doc: Selection.page (the Page tool's page); engine: layout.selectPage
  (no undo step).
- layout.pageSize `anchor` (0-8): the page grows away from the
  reference point and its objects and guides keep their place relative
  to it (a handle drag anchors the opposite edge); liquid rules work
  from that.
- canvas: PageHandles and Tag overlays; translations for the tags and
  the Resize Page undo label.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 00:29:55 +02:00

645 lines
34 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.
use serde_json::json;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s
}
#[test]
fn create_frame_type_and_undo() {
let mut s = session();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap();
let st = s.execute("story.get", &json!({"story": sid})).unwrap();
assert_eq!(st["text"], "Hello, world");
s.execute("text.delete", &json!({})).unwrap();
assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "Hello, worl");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "Hello, world");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.stories.is_empty());
}
#[test]
fn smart_quotes_and_formatting() {
let mut s = session();
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
s.execute("text.insert", &json!({"text": "\"hi\" it's"})).unwrap();
let t = s.execute("document.inspect", &json!({})).unwrap();
let sid = t["stories"][0]["id"].as_u64().unwrap();
assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "\u{201C}hi\u{201D} it\u{2019}s");
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 24, "fontStyle": "Bold"}})).unwrap();
let a = s.execute("type.selectionAttrs", &json!({})).unwrap();
assert_eq!(a["chars"]["size"], 24.0);
s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], "center");
}
#[test]
fn tool_gesture_creates_one_undo_step() {
let mut s = session();
s.set_tool("rectangle");
let v = ViewInfo::at_zoom(1.0);
use designcraft_tools::{PointerEvent, PointerKind};
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), v).unwrap();
for i in 1..10 {
s.pointer(&PointerEvent::new(PointerKind::Drag, 10.0 + i as f64 * 10.0, 10.0 + i as f64 * 5.0), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 55.0), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), 1);
assert_eq!(st.doc.spreads[0].items.len(), 1);
assert_eq!(st.doc.spreads[0].items[0].bounds(), designcraft_geom::Rect::new(10.0, 10.0, 100.0, 55.0));
// Move with the selection tool.
s.set_tool("selection");
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 70.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 40.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 40.0), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), 2);
assert_eq!(st.doc.spreads[0].items[0].bounds().x0, 50.0);
// A move that ends near the margin snaps to it.
s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 48.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 48.0, 30.0), v).unwrap();
// The rectangle tool's 1 pt centered stroke sits half a point outside the path.
// The visible edge lands on the 36 pt margin.
let it = &s.doc().unwrap().doc.spreads[0].items[0];
assert!((it.visible_bounds().x0 - 36.0).abs() < 1e-6, "visible {:?} path {:?}", it.visible_bounds(), it.bounds());
}
#[test]
fn shift_move_does_not_take_the_other_axis() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [80.0, 100.0, 140.0, 140.0], "content": "unassigned"})).unwrap();
s.execute("guide.add", &json!({"orientation": "horizontal", "position": 102.0, "page": 0})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let shift = designcraft_tools::Mods { shift: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 120.0).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 121.0).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 121.0).with_mods(shift), v).unwrap();
let b = s.doc().unwrap().doc.spreads[0].items[0].bounds();
assert!((b.y0 - 100.0).abs() < 1e-6, "locked axis jumped to {b:?}");
assert!(b.x0 > 80.0);
}
#[test]
fn cmd_move_does_not_snap() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [80.0, 100.0, 140.0, 140.0], "content": "unassigned"})).unwrap();
s.execute("guide.add", &json!({"orientation": "horizontal", "position": 102.0, "page": 0})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let cmd = designcraft_tools::Mods { cmd: true, shift: false, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 110.0, 120.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 121.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 121.0).with_mods(cmd), v).unwrap();
let b = s.doc().unwrap().doc.spreads[0].items[0].bounds();
assert!((b.y0 - 101.0).abs() < 1e-6, "cmd move snapped to {b:?}");
assert!(b.x0 > 80.0);
}
#[test]
fn cmd_resize_still_snaps_to_a_guide() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 250.0, "page": 0})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let b0 = s.doc().unwrap().doc.spreads[0].items[0].bounds();
let right = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(b0.x1, b0.center().y));
let cmd = designcraft_tools::Mods { cmd: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, right.x, right.y).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, right.x + 47.0, right.y).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, right.x + 47.0, right.y).with_mods(cmd), v).unwrap();
let it = &s.doc().unwrap().doc.spreads[0].items[0];
let b = it.bounds();
assert!((b.x1 - 250.0).abs() < 1e-6, "cmd resize did not snap the moving edge: {b:?}");
assert!((b.x0 - b0.x0).abs() < 1e-6 && (b.y0 - b0.y0).abs() < 1e-6 && (b.y1 - b0.y1).abs() < 1e-6, "{b:?}");
let expect = (b.x1 - b.x0).abs() / b0.width() * (b.y1 - b.y0).abs() / b0.height();
assert!((it.stroke.weight - expect.sqrt()).abs() < 1e-6, "content scale weight {} want {}", it.stroke.weight, expect.sqrt());
}
#[test]
fn shift_move_on_a_turned_spread_stays_on_the_line() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [80.0, 100.0, 140.0, 140.0], "content": "unassigned"})).unwrap();
s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap();
// Vertical guide sits on the spread axis that a screen-horizontal Shift lock must not offer.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 82.0, "page": 0})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let b0 = s.doc().unwrap().doc.spreads[0].items[0].bounds();
let c = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), b0.center());
let shift = designcraft_tools::Mods { shift: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 60.0, c.y + 1.0).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, c.x + 60.0, c.y + 1.0).with_mods(shift), v).unwrap();
let b = s.doc().unwrap().doc.spreads[0].items[0].bounds();
assert!((b.x0 - b0.x0).abs() < 1e-6, "locked screen axis jumped to {b:?}");
assert!((b.y0 - b0.y0).abs() > 1.0, "free axis did not move: {b:?}");
}
#[test]
fn pages_and_layers() {
let mut s = session();
s.execute("layout.pages.insert", &json!({"count": 3})).unwrap();
assert_eq!(s.doc().unwrap().doc.page_count(), 5);
s.execute("layout.pages.delete", &json!({"pages": [0]})).unwrap();
assert_eq!(s.doc().unwrap().doc.page_count(), 4);
s.execute("layer.new", &json!({"name": "Text"})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].name, "Text");
}
#[test]
fn sample_document_is_valid_and_composes() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let st = s.doc().unwrap();
st.doc.check().unwrap();
assert_eq!(st.doc.page_count(), 4);
let i = s.execute("document.inspect", &json!({})).unwrap();
assert!(i["stories"].as_array().unwrap().len() >= 8);
// Save/load round trip.
let bytes = cmd::file_bytes(&s.doc().unwrap().doc);
let back = cmd::file_from(&bytes).unwrap();
assert_eq!(back.page_count(), 4);
assert_eq!(back.assets.len(), s.doc().unwrap().doc.assets.len());
assert!(back.assets.values().all(|a| !a.data.is_empty()));
}
#[test]
fn every_command_has_metadata() {
let mut ids = std::collections::HashSet::new();
for c in command_specs() {
assert!(ids.insert(c.id), "duplicate command {}", c.id);
assert!(!c.label.is_empty() && !c.params.is_empty(), "{}", c.id);
}
assert!(command_specs().len() > 60);
}
#[test]
fn align_and_distribute() {
let mut s = session();
let mut ids = vec![];
for (x, w) in [(10.0, 20.0), (100.0, 40.0), (300.0, 10.0)] {
let r = s.execute("frame.create", &json!({"rect": [x, 50.0 + x / 10.0, x + w, 100.0], "content": "unassigned"})).unwrap();
ids.push(r["id"].as_u64().unwrap());
}
s.execute("object.align", &json!({"ids": ids, "edge": "top"})).unwrap();
let st = s.doc().unwrap();
let tops: Vec<f64> = ids.iter().map(|i| st.doc.item(designcraft_doc::ItemId(*i)).unwrap().bounds().y0).collect();
assert!(tops.iter().all(|t| (*t - 51.0).abs() < 1e-9), "{tops:?}");
s.execute("object.distribute", &json!({"ids": ids, "by": "spacing"})).unwrap();
let st = s.doc().unwrap();
let b: Vec<designcraft_geom::Rect> = ids.iter().map(|i| st.doc.item(designcraft_doc::ItemId(*i)).unwrap().bounds()).collect();
assert!(((b[1].x0 - b[0].x1) - (b[2].x0 - b[1].x1)).abs() < 1e-9);
s.execute("object.align", &json!({"ids": [ids[0]], "edge": "left", "to": "margins"})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(ids[0])).unwrap().bounds().x0, 36.0);
}
#[test]
fn pen_draws_and_direct_selection_edits() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let v = ViewInfo::at_zoom(1.0);
s.set_tool("pen");
let click = |s: &mut Session, x: f64, y: f64| {
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
};
click(&mut s, 100.0, 100.0);
click(&mut s, 200.0, 100.0);
// A smooth point (drag).
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 240.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 240.0, 200.0), v).unwrap();
click(&mut s, 100.0, 100.0); // close
let st = s.doc().unwrap();
let it = &st.doc.spreads[0].items[0];
assert_eq!(it.path.subpaths[0].anchors.len(), 3);
assert!(it.path.subpaths[0].closed);
assert!(it.path.subpaths[0].anchors[2].has_out());
let id = it.id;
// Direct Selection: drag the first anchor.
s.set_tool("directSelection");
s.execute("selection.set", &json!({"ids": [id.0]})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 80.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 80.0), v).unwrap();
let a = s.doc().unwrap().doc.item(id).unwrap().path.subpaths[0].anchors[0].p;
// The pen placed this anchor on the baseline at y 96. Dragging toward (90, 80)
// proposes y 76, and the baseline at 72 is inside the zone, so the anchor lands at (90, 72).
assert_eq!(a, designcraft_geom::Point::new(90.0, 72.0));
}
#[test]
fn rotate_scale_shear_and_eyedropper() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0"})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [300, 100, 340, 140], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("transform.rotate", &json!({"ids": [a], "angle": 90})).unwrap();
let r = s.doc().unwrap().doc.item(designcraft_doc::ItemId(a)).unwrap().bounds();
assert!((r.width() - 50.0).abs() < 1e-6 && (r.height() - 100.0).abs() < 1e-6, "{r:?}");
s.execute("transform.scale", &json!({"ids": [b], "sx": 2.0})).unwrap();
let r = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds();
assert!((r.width() - 80.0).abs() < 1e-6, "{r:?}");
s.execute("selection.set", &json!({"ids": [b]})).unwrap();
s.execute("object.matchAttributes", &json!({"from": a})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().fill.swatch, "C=100 M=0 Y=0 K=0");
s.execute("transform.shear", &json!({"ids": [b], "angle": 20})).unwrap();
}
#[test]
fn place_gun_click_drag_and_into_frame() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
let b64 = cmd::base64_encode(&png);
s.execute("place.load", &json!({"base64": b64, "name": "a.png"})).unwrap();
assert_eq!(s.tool_id(), "placeGun");
let v = ViewInfo::at_zoom(1.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 300.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 300.0), v).unwrap();
let st = s.doc().unwrap();
let it = st.doc.spreads[0].items.last().unwrap();
// Dragged 80×200 → fit proportionally: 80×40.
assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 100.0, 180.0, 140.0));
assert_eq!(s.tool_id(), "selection");
// Into an empty frame.
let f = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
s.execute("place.load", &json!({"base64": b64})).unwrap();
s.execute("place.drop", &json!({"frame": f})).unwrap();
let graphic_xf = |s: &Session| match &s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content {
designcraft_doc::Content::Graphic(g) => g.xf,
_ => panic!("not a graphic"),
};
let placed = graphic_xf(&s);
// Dragging the top edge down crops the top only: the image stays where it is.
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
s.execute("transform.resize", &json!({"from": [300, 300, 400, 400], "to": [300, 340, 400, 400]})).unwrap();
assert_eq!(graphic_xf(&s), placed);
}
#[test]
fn preflight_reports_overset_and_missing_fonts() {
let mut s = session();
s.execute("frame.create", &json!({"rect": [36, 36, 100, 50], "content": "text", "text": "This text will not fit in such a tiny frame at all."}))
.unwrap();
s.execute("text.select", &json!({"story": s.doc().unwrap().doc.stories.keys().next().unwrap().0, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.char", &json!({"attrs": {"fontFamily": "Nonexistent Sans"}})).unwrap();
let r = s.execute("preflight.run", &json!({})).unwrap();
let kinds: Vec<&str> = r["issues"].as_array().unwrap().iter().map(|i| i["kind"].as_str().unwrap()).collect();
assert!(kinds.contains(&"overset") && kinds.contains(&"missingFont"), "{kinds:?}");
assert_eq!(r["errors"], 2);
}
#[test]
fn hyperlinks_and_bookmarks_reach_the_pdf() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
s.execute("selection.set", &json!({"ids": [39]})).unwrap();
s.execute("hyperlink.create", &json!({"url": "https://example.com/designcraft"})).unwrap();
let d = s.execute("document.inspect", &json!({})).unwrap();
let sid = d["stories"][0]["id"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap();
s.execute("hyperlink.create", &json!({"page": 3})).unwrap();
s.execute("bookmark.add", &json!({"name": "Cover", "page": 1})).unwrap();
s.execute("bookmark.add", &json!({"name": "Feature", "page": 2})).unwrap();
assert_eq!(s.execute("hyperlink.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
let r = s.execute("file.exportPdf", &json!({})).unwrap();
let bytes = cmd::base64_decode(r["base64"].as_str().unwrap());
let pdf = hayro_syntax::Pdf::new(bytes).expect("valid pdf");
assert_eq!(pdf.pages().len(), 4);
// Round trip through the native format keeps them.
let back = cmd::file_from(&cmd::file_bytes(&s.doc().unwrap().doc)).unwrap();
assert_eq!(back.hyperlinks.len(), 2);
assert_eq!(back.bookmarks.len(), 2);
}
#[test]
fn serialized_document_opens_again() {
// The web app saves (downloads) what `file.serialize` returns: it must be the file's exact bytes.
let mut s = session();
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved on the web"})).unwrap();
let r = s.execute("file.serialize", &json!({})).unwrap();
let bytes = cmd::base64_decode(r["base64"].as_str().expect("base64"));
assert_eq!(bytes, cmd::to_bytes(&s.doc().unwrap().doc));
s.execute("file.openBytes", &json!({"name": "copy", "base64": r["base64"]})).unwrap();
let d = s.execute("document.inspect", &json!({})).unwrap();
assert_eq!(d["stories"][0]["preview"], "Saved on the web");
}
#[test]
fn active_layer_survives_undoing_new_layer() {
let mut s = session();
let layer_of = |s: &mut Session, id: u64| {
let d = s.execute("document.inspect", &json!({})).unwrap();
let items = d["spreads"].as_array().unwrap().iter().flat_map(|sp| sp["items"].as_array().unwrap().clone()).collect::<Vec<_>>();
let it = items.into_iter().find(|i| i["id"] == id).unwrap();
let layers: Vec<Value> = d["layers"].as_array().unwrap().iter().map(|l| l["id"].clone()).collect();
(it["layer"].clone(), layers)
};
// New Layer, then Undo: the new layer is gone, so it can't stay the active one.
s.execute("layer.new", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Visible"})).unwrap();
let (layer, layers) = layer_of(&mut s, f["id"].as_u64().unwrap());
assert!(layers.contains(&layer), "frame on layer {layer} not in {layers:?}");
// Delete Unused Layers removing the (empty) active layer.
s.execute("layer.new", &json!({})).unwrap();
s.execute("layer.deleteUnused", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [36, 236, 300, 400], "content": "text", "text": "Also visible"})).unwrap();
let (layer, layers) = layer_of(&mut s, f["id"].as_u64().unwrap());
assert!(layers.contains(&layer), "frame on layer {layer} not in {layers:?}");
}
#[test]
fn step_and_repeat_grid() {
let mut s = session();
s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap();
let r = s.execute("edit.stepAndRepeat", &json!({"rows": 3, "columns": 4, "dx": 40, "dy": 40})).unwrap();
assert_eq!(r["created"], 11);
assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), 12);
let last = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert_eq!(last.x0, 36.0 + 3.0 * 40.0);
assert_eq!(last.y0, 36.0 + 2.0 * 40.0);
}
/// rows × columns once overflowed: a panic in debug builds, and in release builds a product that
/// wrapped to 0 passed the 1000-copy limit and started a grid of 2⁶⁴ copies.
#[test]
fn step_and_repeat_with_a_huge_grid_makes_no_grid() {
let mut s = session();
s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap();
let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len();
let before = items(&s);
// Counts past the limit are an error, and nothing is copied.
assert!(s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).is_err());
assert_eq!(items(&s), before);
}
/// The column count is the caller's and sizes a list every time the page's columns are laid out.
#[test]
fn new_document_caps_the_column_count() {
let mut s = Session::new();
s.execute("file.new", &json!({"columns": 4_000_000_000_u64})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216);
// Past u32 the count wrapped (2³² + 3 became 3).
s.execute("file.new", &json!({"columns": 4_294_967_299_u64})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216);
}
#[test]
fn snippets_roundtrip_between_documents() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
s.execute("selection.set", &json!({"ids": [39]})).unwrap();
let snip = s.execute("snippet.export", &json!({})).unwrap();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("snippet.place", &json!({"base64": snip["base64"], "x": 36.0, "y": 36.0})).unwrap();
let id = designcraft_doc::ItemId(r["ids"][0].as_u64().unwrap());
let st = s.doc().unwrap();
let it = st.doc.item(id).unwrap();
assert_eq!(it.bounds().x0, 36.0);
let sid = it.text_frame().unwrap().story;
assert!(st.doc.story(sid).unwrap().text.starts_with("Every page begins"));
assert!(st.doc.styles.para("Body").is_some());
st.doc.check().unwrap();
}
#[test]
fn object_styles_create_and_apply() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [36, 36, 136, 136], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.fill", &json!({"swatch": "C=0 M=100 Y=0 K=0"})).unwrap();
s.execute("object.stroke", &json!({"weight": 4.0})).unwrap();
s.execute("style.object.create", &json!({"name": "Magenta Box"})).unwrap();
let b = s.execute("frame.create", &json!({"rect": [200, 36, 300, 136], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("style.object.apply", &json!({"name": "Magenta Box"})).unwrap();
let d = &s.doc().unwrap().doc;
let (ia, ib) = (d.item(designcraft_doc::ItemId(a)).unwrap(), d.item(designcraft_doc::ItemId(b)).unwrap());
assert_eq!(ia.fill, ib.fill);
assert_eq!(ib.stroke.weight, 4.0);
assert_eq!(ib.object_style, "Magenta Box");
}
/// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText).
fn pdf_text(bytes: &[u8]) -> Vec<String> {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
struct Ex(String);
impl Device<'_> for Ex {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, g: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
match g.as_unicode() {
Some(hayro_cmap::BfString::Char(c)) => self.0.push(c),
Some(hayro_cmap::BfString::String(s)) => self.0.push_str(&s),
None => self.0.push('\u{FFFD}'),
}
}
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("valid PDF");
let cache = InterpreterCache::new();
pdf.pages()
.iter()
.map(|page| {
let mut ctx =
Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
let mut ex = Ex(String::new());
interpret_page(page, &mut ctx, &mut ex);
ex.0
})
.collect()
}
#[test]
fn export_pdf_of_the_sample() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let r = s.execute("file.exportPdf", &json!({"bleed": true, "marks": true})).unwrap();
assert_eq!(r["pages"], 4);
let bytes = cmd::base64_decode(r["base64"].as_str().unwrap());
assert!(bytes.starts_with(b"%PDF-"));
let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("parse");
assert_eq!(pdf.pages().len(), 4);
let raw = String::from_utf8_lossy(&bytes);
assert!(raw.contains("/FontFile2") || raw.contains("/FontFile3"), "fonts embedded");
let text = pdf_text(&bytes);
let all: String = text.join("\n");
assert!(all.contains("Notes on the Grid"), "{all}");
// Page range + spreads + file output.
let dir = std::env::temp_dir().join(format!("dc-pdf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("out.pdf");
let r = s.execute("file.exportPdf", &json!({"path": path.to_string_lossy(), "pages": "2-3", "spreads": true})).unwrap();
assert_eq!(r["pages"], 1, "pages 2–3 are one spread");
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-"));
std::fs::remove_dir_all(&dir).ok();
assert!(s.execute("file.exportPdf", &json!({"pages": "9"})).is_err());
assert!(s.execute("file.exportPdf", &json!({"standard": "bogus"})).is_err());
}
#[test]
fn object_set_flags_and_wrap_invert() {
let mut s = session();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200]})).unwrap();
let id = r["id"].as_u64().unwrap();
let item = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).cloned().unwrap();
s.execute("object.setFlags", &json!({"ids": [id], "hidden": true})).unwrap();
assert!(item(&s).hidden);
s.execute("object.setFlags", &json!({"ids": [id], "hidden": false, "locked": true})).unwrap();
assert!(!item(&s).hidden && item(&s).locked);
s.execute("object.setFlags", &json!({"ids": [id], "locked": false})).unwrap();
assert!(!item(&s).locked);
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("object.textWrap", &json!({"mode": "boundingBox", "invert": true})).unwrap();
assert!(item(&s).wrap.invert);
}
#[test]
fn gridify_while_drawing_frames() {
use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
let n0 = s.doc().unwrap().doc.spreads[0].items.len();
s.set_tool("rectangleFrame");
let v = ViewInfo::at_zoom(1.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 312.0, 212.0), v).unwrap();
for k in [ToolKey::Right, ToolKey::Right, ToolKey::Up, ToolKey::Left] {
assert!(s.tool_key(k, Mods::default(), v).unwrap(), "arrow keys gridify while dragging");
}
s.pointer(&PointerEvent::new(PointerKind::Up, 312.0, 212.0), v).unwrap();
let d = &s.doc().unwrap().doc;
let new: Vec<_> = d.spreads[0].items[n0..].iter().map(|i| i.bounds()).collect();
assert_eq!(new.len(), 4, "2 columns × 2 rows");
assert!((new[0].width() - 100.0).abs() < 1e-6 && (new[1].x0 - 212.0).abs() < 1e-6, "12 pt gutters: {new:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), n0, "one undo step");
}
#[test]
fn gap_tool_drag_moves_the_gap() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 300]})).unwrap()["id"].as_u64().unwrap();
s.set_tool("gap");
let v = ViewInfo::at_zoom(1.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 270.0, 205.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 270.0, 205.0), v).unwrap();
// The gap between the frame and the page's right edge: the frame's right side moved 20.
let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(a)).unwrap().bounds();
assert_eq!(b.x1, 220.0, "{b:?}");
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "create + one gap move");
}
/// Parameters reach commands from the control channel, MCP and scripts as any JSON value.
/// Commands that add a key to their parameters (`q["fixedLayout"] = …`) panicked on an array.
#[test]
fn commands_take_non_object_params_without_panicking() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for p in [json!([1, 2]), json!("text"), json!(3), Value::Null] {
let r = s.execute("file.exportFixedEpub", &p);
assert!(r.is_ok(), "{p}: {r:?}");
}
}
#[test]
fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() {
use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap();
let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds();
let width = |s: &Session| s.doc().unwrap().doc.spreads[0].pages[0].width;
// The page on the canvas (a single page is centred on the spine).
let page = |s: &Session| {
let d = &s.doc().unwrap().doc;
CanvasLayout::new(d, false).page_rect(d, 0).unwrap()
};
s.set_tool("page");
let v = ViewInfo::at_zoom(1.0);
let ev = |kind, x: f64, y: f64, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, x, y) };
let drag = |s: &mut Session, from: (f64, f64), by: f64, alt: bool, check: &dyn Fn(&Session)| {
s.pointer(&ev(PointerKind::Down, from.0, from.1, alt), v).unwrap();
s.pointer(&ev(PointerKind::Drag, from.0 + by, from.1, alt), v).unwrap();
check(s);
s.pointer(&ev(PointerKind::Up, from.0 + by, from.1, alt), v).unwrap();
};
// A click selects the page.
let c = page(&s).center();
s.pointer(&ev(PointerKind::Down, c.x, c.y, false), v).unwrap();
s.pointer(&ev(PointerKind::Up, c.x, c.y, false), v).unwrap();
assert_eq!(s.doc().unwrap().selection.page, Some(0));
// A plain drag of the right handle previews the page's liquid rule and springs back.
s.execute("liquid.pageRule", &json!({"rule": "scale"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = page(&s);
drag(&mut s, (r.x1, r.center().y), 612.0, false, &|s| {
assert_eq!(width(s), 1224.0, "previewed");
// Scale keeps proportions: only wider, so the frame is centred on the wider page.
assert_eq!(frame(s).x0, 706.0, "the Scale rule in the preview: {:?}", frame(s));
});
assert_eq!((width(&s), frame(&s)), (612.0, designcraft_geom::Rect::new(400.0, 100.0, 500.0, 200.0)), "sprung back");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing to undo");
// Alt-drag resizes; with the rule off the frame stays where it is.
s.execute("liquid.pageRule", &json!({"rule": "off"})).unwrap();
let r = page(&s);
drag(&mut s, (r.x1, r.center().y), 100.0, true, &|_| {});
assert_eq!(width(&s), 712.0);
assert_eq!(frame(&s).x0, 400.0);
// The left handle grows the page to the left: the frame keeps its place by the right edge.
let r = page(&s);
drag(&mut s, (r.x0, r.center().y), -100.0, true, &|_| {});
let pg = s.doc().unwrap().doc.spreads[0].pages[0].clone();
assert_eq!(pg.width, 812.0);
assert_eq!(frame(&s).x0 - pg.x, 500.0, "100 pt further from the page's left edge");
// A click on the pasteboard deselects the page.
let r = page(&s);
s.pointer(&ev(PointerKind::Down, r.x0 - 200.0, r.center().y, false), v).unwrap();
assert_eq!(s.doc().unwrap().selection.page, None);
}
#[test]
fn page_size_keeps_objects_by_its_anchor() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds();
// Bottom-right stays put: 100 wider and 50 taller moves the frame 100 right and 50 down.
s.execute("layout.pageSize", &json!({"pages": [1], "width": 500, "height": 450, "anchor": 8})).unwrap();
assert_eq!(frame(&s), designcraft_geom::Rect::new(200.0, 150.0, 300.0, 250.0));
// The centre: half of it.
s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "height": 450, "anchor": 4})).unwrap();
assert_eq!(frame(&s).x0, 300.0);
assert!(s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "anchor": 9})).is_err());
assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err());
assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1);
}