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()); } /// A command that runs another one (Cut runs Clear) recorded an undo step for each, so the /// second Undo did nothing. One command is one step, whatever it runs inside. #[test] fn a_command_that_runs_another_is_one_undo_step() { let mut s = session(); let one_step = |s: &mut Session, id: &str, p: Value, label: &str| { let (before, steps) = { let st = s.doc().unwrap(); (st.doc.clone(), st.history.undo.len()) }; s.execute(id, &p).unwrap(); let st = s.doc().unwrap(); assert_ne!(st.doc, before, "{id} changes the document"); assert_eq!(st.history.undo.len(), steps + 1, "{id}"); assert_eq!(st.history.undo.last().unwrap().label, label); let after = st.doc.clone(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before, "one Undo takes {id} back"); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, after, "one Redo brings {id} back"); s.execute("edit.undo", &json!({})).unwrap(); }; // Pasting text runs Type. let story = s.execute("frame.create", &json!({"rect": [36, 300, 300, 400], "content": "text", "text": "ab"})).unwrap()["story"].clone(); s.execute("text.select", &json!({"story": story, "anchor": 2})).unwrap(); one_step(&mut s, "edit.paste", json!({"text": "c"}), "Paste"); let a = s.execute("frame.create", &json!({"rect": [36, 36, 136, 136]})).unwrap()["id"].clone(); s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap(); for (id, label) in [("edit.duplicate", "Duplicate"), ("object.bringToFront", "Bring to Front"), ("edit.cut", "Cut"), ("edit.pasteInPlace", "Paste in Place")] { s.execute("selection.set", &json!({"ids": [a]})).unwrap(); one_step(&mut s, id, json!({}), label); } // A script that runs Cut is three commands deep and still one step. let script = format!("selection.set {{\"ids\": [{a}]}}\nedit.cut {{}}"); one_step(&mut s, "script.run", json!({"text": script}), "Run Script"); } /// What a command did before it failed is an undo step too: a script that stopped at a bad /// step left its earlier steps in the document with nothing to undo. #[test] fn a_command_that_fails_partway_can_be_undone() { let mut s = session(); let before = s.doc().unwrap().doc.clone(); let script = "frame.create {\"rect\": [36, 36, 136, 136]}\nnope.command"; assert!(s.execute("script.run", &json!({"text": script})).is_err()); let st = s.doc().unwrap(); assert_eq!(st.doc.spreads[0].items.len(), 1, "the first step ran"); assert_eq!(st.history.undo.len(), 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before); // The next command records its step as usual. s.execute("frame.create", &json!({"rect": [36, 36, 136, 136]})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), 1); } /// The same for a command that fails after a command it ran changed the document: that one /// leaves the step to it, so it has to record one. #[test] fn a_command_that_fails_after_the_one_it_ran_can_be_undone() { let mut s = session(); let empty = s.execute("frame.create", &json!({"rect": [36, 36, 136, 136], "content": "unassigned"})).unwrap()["id"].clone(); let text = s.execute("frame.create", &json!({"rect": [36, 300, 300, 400], "content": "text", "text": "abc"})).unwrap()["id"].clone(); s.execute("selection.set", &json!({"ids": [text]})).unwrap(); let (before, steps) = { let st = s.doc().unwrap(); (st.doc.clone(), st.history.undo.len()) }; // Fill with Placeholder Text turns the empty frame into a text frame, then finds no such story. assert!(s.execute("type.fillWithPlaceholder", &json!({"frame": empty, "story": 99999})).is_err()); let st = s.doc().unwrap(); assert_ne!(st.doc, before, "the frame was converted"); assert_eq!(st.history.undo.len(), steps + 1); assert_eq!(st.history.undo.last().unwrap().label, "Fill with Placeholder Text"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before); } /// A script's steps still record as it runs: an Undo step takes back the step before it, not /// what was done before the script. The script stays one step. #[test] fn a_script_that_undoes_its_own_step_is_one_undo_step() { let mut s = session(); s.execute("frame.create", &json!({"rect": [36, 36, 136, 136]})).unwrap(); let script = "frame.create {\"rect\": [200, 36, 300, 136]}\nframe.create {\"rect\": [400, 36, 500, 136]}\nedit.undo {}"; s.execute("script.run", &json!({"text": script})).unwrap(); let st = s.doc().unwrap(); assert_eq!(st.doc.spreads[0].items.len(), 2, "the frame from before the script and the script's first"); assert_eq!(st.history.undo.len(), 2); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), 1); } /// A command that is no undo step itself (placing the caret) leaves the steps of the commands /// it runs alone: turning the empty frame into a text frame can be undone. #[test] fn a_command_without_an_undo_step_keeps_the_steps_of_those_it_runs() { let mut s = session(); let id = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "unassigned"})).unwrap()["id"].clone(); s.execute("text.placeCaret", &json!({"frame": id, "point": [50, 50]})).unwrap(); let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect(); assert_eq!(labels, ["Create Frame", "Content"]); } #[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 = 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 = 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::>(); let it = items.into_iter().find(|i| i["id"] == id).unwrap(); let layers: Vec = 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 { 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>) {} 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>, _: 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); // Side and contour type keep the mode; contour types that need the image aren't accepted. s.execute("object.textWrap", &json!({"mode": "contour"})).unwrap(); s.execute("object.textWrap", &json!({"side": "largestArea", "contour": "graphicFrame"})).unwrap(); let w = item(&s).wrap; assert_eq!( (w.mode, w.side, w.contour), (designcraft_doc::WrapMode::Contour, designcraft_doc::WrapSide::LargestArea, designcraft_doc::ContourType::GraphicFrame) ); assert!(s.execute("object.textWrap", &json!({"contour": "detectEdges"})).is_err()); assert!(s.execute("object.textWrap", &json!({"side": "middle"})).is_err()); } #[test] fn text_wrap_on_selected_content_sets_the_graphic_wrap() { let mut s = session(); let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); 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": cmd::base64_encode(&png)})).unwrap(); s.execute("place.drop", &json!({"frame": f})).unwrap(); let item = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).cloned().unwrap(); s.execute("selection.set", &json!({"ids": [f], "content": true})).unwrap(); s.execute("object.textWrap", &json!({"mode": "boundingBox", "offset": 4})).unwrap(); let it = item(&s); assert_eq!(it.wrap.mode, designcraft_doc::WrapMode::None, "the frame keeps no wrap"); let g = it.graphic().unwrap(); assert_eq!((g.wrap.mode, g.wrap.offsets), (designcraft_doc::WrapMode::BoundingBox, [4.0; 4])); // The frame selected: the frame's own wrap. s.execute("selection.set", &json!({"ids": [f]})).unwrap(); s.execute("object.textWrap", &json!({"mode": "jumpObject"})).unwrap(); let it = item(&s); assert_eq!((it.wrap.mode, it.graphic().unwrap().wrap.mode), (designcraft_doc::WrapMode::JumpObject, designcraft_doc::WrapMode::BoundingBox)); } #[test] fn custom_wrap_path_follows_resize_and_yields_to_offsets() { let mut s = session(); let r = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap(); let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); s.execute("selection.set", &json!({"ids": [id.0]})).unwrap(); s.execute("object.textWrap", &json!({"mode": "boundingBox"})).unwrap(); let inner = s.doc().unwrap().doc.item(id).unwrap().path.clone(); let custom = designcraft_geom::PathData::single(designcraft_geom::SubPath::polyline( &[designcraft_geom::Point::new(0.0, 0.0), designcraft_geom::Point::new(40.0, 0.0), designcraft_geom::Point::new(0.0, 100.0)], true, )); s.edit(|d, _| { if let Some(it) = d.item_mut(id) { it.wrap.path = Some(custom.clone()); } Ok(()) }) .unwrap(); let wrap_path = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().wrap.path.clone(); // Side and mode keep the boundary. s.execute("object.textWrap", &json!({"side": "rightSide"})).unwrap(); s.execute("object.textWrap", &json!({"mode": "contour"})).unwrap(); assert_eq!(wrap_path(&s), Some(custom.clone())); // Resizing scales it with the frame's path. let b = inner.bounds().unwrap(); let to = [b.x0, b.y0, b.x0 + 2.0 * b.width(), b.y1]; s.execute("transform.resize", &json!({"from": [b.x0, b.y0, b.x1, b.y1], "to": to})).unwrap(); let w = wrap_path(&s).unwrap().bounds().unwrap(); assert!((w.width() - 80.0).abs() < 1e-6 && (w.height() - 100.0).abs() < 1e-6, "{w:?}"); // An offset rebuilds the wrap from the frame. s.execute("object.textWrap", &json!({"offset": 6})).unwrap(); assert_eq!(wrap_path(&s), None); } #[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:?}"); } } /// Load the text from a frame's out port with the Selection tool and click on the pasteboard: /// a new frame is threaded there and the overset text flows into it. #[test] fn threading_with_the_loaded_text_cursor_on_the_sample() { use designcraft_tools::{PointerEvent, PointerKind}; let mut s = Session::new(); s.execute("file.newSample", &json!({})).unwrap(); let d = s.doc().unwrap().doc.clone(); let (sid, first) = d.stories.values().find(|st| st.frames.len() >= 2).map(|st| (st.id, st.frames[0])).expect("a threaded story"); // Break the thread so the first frame is overset, and select it. s.execute("text.unthread", &json!({"frame": first.0})).unwrap(); assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).is_overset()); s.execute("selection.set", &json!({"ids": [first.0]})).unwrap(); s.set_tool("selection"); let v = ViewInfo::at_zoom(1.0); let layout = s.layout(); let port = designcraft_tools::thread::port_center(&s.doc().unwrap().doc, &layout, first, true, v.zoom).unwrap(); let ev = |k, p: designcraft_geom::Point| PointerEvent::new(k, p.x, p.y); s.pointer(&ev(PointerKind::Down, port), v).unwrap(); s.pointer(&ev(PointerKind::Up, port), v).unwrap(); assert_eq!(s.cursor(port + designcraft_geom::Vec2::new(-2000.0, 0.0), Default::default(), v), designcraft_tools::Cursor::LoadedText); // On the pasteboard left of the first spread. let b = layout.slots[0].bounds; let at = designcraft_geom::Point::new(b.x0 - 300.0, b.y0 + 100.0); let undo = s.doc().unwrap().history.undo.len(); s.pointer(&ev(PointerKind::Down, at), v).unwrap(); s.pointer(&ev(PointerKind::Up, at), v).unwrap(); let st = s.doc().unwrap(); assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); let frames = st.doc.story(sid).unwrap().frames.clone(); assert_eq!(frames.len(), 2); let new = frames[1]; assert_eq!(st.selection.items, [new]); let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); assert!(cs.frames.iter().any(|f| f.frame == new && !f.range.is_empty()), "the text flows into the new frame"); let nb = s.doc().unwrap().doc.item(new).unwrap().bounds(); assert!((nb.y0 - layout.to_spread(designcraft_doc::SpreadRef::Doc(0), at).y).abs() < 1e-6, "{nb:?}"); } /// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units). fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) { use designcraft_tools::{PointerEvent, PointerKind}; let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at); s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap(); } #[test] fn text_frame_resize_snaps_to_the_margins() { use designcraft_geom::{Point, Vec2}; for zoom in [1.0, 0.5] { let mut s = Session::new(); s.execute("file.newSample", &json!({})).unwrap(); let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect(); let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0); s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap(); s.set_tool("selection"); let v = ViewInfo::at_zoom(zoom); let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); // Right handle to 1.5 pt short of the right margin. drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v); let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1); // Bottom handle to 1.5 pt past the bottom margin. drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v); let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1); // Top-left corner handle onto the top and left margins. drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v); let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}"); } } #[test] fn type_tool_frames_snap_to_the_margins() { use designcraft_geom::Point; use designcraft_tools::{PointerEvent, PointerKind}; let mut s = session(); let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect(); s.set_tool("type"); let v = ViewInfo::at_zoom(1.0); let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y)); // From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin. let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5); let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5); let before = s.doc().unwrap().doc.spreads[0].items.len(); s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap(); let items = &s.doc().unwrap().doc.spreads[0].items; assert_eq!(items.len(), before + 1, "a frame was drawn"); let r = items.last().unwrap().bounds(); assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); } #[test] fn preflight_distinguishes_unplaced_stories_and_locates_footnote_overset() { use designcraft_doc::{AnchorPosition, AnchoredObject, ParaFormat, SpreadRef, Story, StoryId}; use designcraft_geom::Rect; let mut s = session(); let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc); let layer = doc.default_layer(); let (host, body) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 400.0, 600.0), layer, "Body", ParaFormat::default()).unwrap(); let (frame, _) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 5.0, 2.0), layer, "Overflow", ParaFormat::default()).unwrap(); let item = doc.item(frame).unwrap().clone(); doc.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != frame); let story = doc.story_mut(body).unwrap(); story.insert_note(4, "Note", ParaFormat::default()); Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); let unplaced = StoryId(doc.alloc()); doc.stories.insert(unplaced, Arc::new(Story::with_text(unplaced, "Unplaced", ParaFormat::default()))); doc.check().unwrap(); let issues = s.execute("preflight.run", &json!({})).unwrap(); let issues = issues["issues"].as_array().unwrap(); let overset: Vec<_> = issues.iter().filter(|i| i["kind"] == "overset").collect(); assert_eq!(overset.len(), 1, "{issues:?}"); assert_eq!(overset[0]["item"], host.0); assert_eq!(overset[0]["page"], 0); let unplaced = issues.iter().find(|i| i["kind"] == "unplacedStory").unwrap(); assert_eq!(unplaced["severity"], "warning"); assert!(unplaced.get("page").is_none()); } #[test] fn preflight_mojikumi_uses_the_same_supported_rules_as_composition() { let mut s = session(); s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "中文 A1"})).unwrap(); let story = s.doc().unwrap().doc.stories.keys().next().unwrap().0; s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 1})).unwrap(); for (name, unsupported) in [ ("SimpChineseDefault", false), ("LineEndAllOneHalfEmEnum", false), ("LineEndAllOneEmEnum", false), ("UnknownPreset", true), ("Nothing", false), ] { s.execute("type.para", &json!({"attrs": {"mojikumi": name, "kinsokuType": "KinsokuPushInFirst"}})).unwrap(); let result = s.execute("preflight.run", &json!({})).unwrap(); let warnings: Vec<_> = result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedTypography").collect(); assert_eq!(!warnings.is_empty(), unsupported, "{name}: {warnings:?}"); } } #[test] fn paragraph_style_edit_persists_attrs_and_rename() { let mut s = session(); let name = s.execute("style.paragraph.create", &json!({"name": "Body Copy", "para": {"align": "left"}})).unwrap()["name"].as_str().unwrap().to_string(); s.execute("style.paragraph.edit", &json!({"name": name, "para": {"align": "center"}})).unwrap(); { let st = s.doc().unwrap(); let style = st.doc.styles.para(&name).expect("style still exists"); assert_eq!(style.para.align, Some(designcraft_doc::Align::Center), "edited attribute persisted"); } // Renaming updates the style definition itself, and every place it's referenced. s.execute("style.paragraph.edit", &json!({"name": name, "rename": "Body Text"})).unwrap(); let st = s.doc().unwrap(); assert!(st.doc.styles.para("Body Text").is_some(), "renamed style exists under the new name"); assert!(st.doc.styles.para(&name).is_none(), "old name is gone"); } #[test] fn paragraph_style_edit_rejects_based_on_cycle() { let mut s = session(); let a = s.execute("style.paragraph.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string(); let b = s.execute("style.paragraph.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string(); let r = s.execute("style.paragraph.edit", &json!({"name": a, "basedOn": b})); assert!(r.is_err(), "A based on B based on A must be rejected"); } #[test] fn paragraph_style_delete_reassigns_affected_text() { let mut s = session(); let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string(); let sid = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap(); s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); s.execute("style.paragraph.apply", &json!({"name": name})).unwrap(); { let st = s.doc().unwrap(); let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); assert_eq!(story.paras[0].style, name, "style applied before delete"); } s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH})).unwrap(); let st = s.doc().unwrap(); assert!(st.doc.styles.para(&name).is_none(), "deleted style is gone"); let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "affected paragraph reassigned to the replacement style"); } #[test] fn paragraph_style_delete_rejects_builtin_styles() { let mut s = session(); let r = s.execute("style.paragraph.delete", &json!({"name": designcraft_doc::BASIC_PARAGRAPH})); assert!(r.is_err(), "built-in styles can't be deleted"); } #[test] fn character_style_edit_persists_attrs_and_rename() { let mut s = session(); let name = s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap()["name"] .as_str() .unwrap() .to_string(); s.execute("style.character.edit", &json!({"name": name, "chars": {"tracking": 20.0}})).unwrap(); { let st = s.doc().unwrap(); let style = st.doc.styles.char_style(&name).expect("style still exists"); assert_eq!(style.chars.tracking, Some(20.0), "edited attribute persisted"); } // Renaming updates the style definition itself, and every place it's referenced. s.execute("style.character.edit", &json!({"name": name, "rename": "Emphasis"})).unwrap(); let st = s.doc().unwrap(); assert!(st.doc.styles.char_style("Emphasis").is_some(), "renamed style exists under the new name"); assert!(st.doc.styles.char_style(&name).is_none(), "old name is gone"); } #[test] fn character_style_edit_rejects_based_on_cycle() { let mut s = session(); let a = s.execute("style.character.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string(); let b = s.execute("style.character.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string(); let r = s.execute("style.character.edit", &json!({"name": a, "basedOn": b})); assert!(r.is_err(), "A based on B based on A must be rejected"); } #[test] fn character_style_delete_reassigns_affected_text() { let mut s = session(); let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); 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(); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); s.execute("style.character.apply", &json!({"name": name})).unwrap(); { let st = s.doc().unwrap(); let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); assert_eq!(story.chars[0].format.style, name, "style applied before delete"); } s.execute("style.character.delete", &json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE})).unwrap(); let st = s.doc().unwrap(); assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone"); let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "affected run reassigned to the replacement style"); } #[test] fn character_style_delete_rejects_builtin_styles() { let mut s = session(); let r = s.execute("style.character.delete", &json!({"name": designcraft_doc::NO_CHAR_STYLE})); assert!(r.is_err(), "built-in styles can't be deleted"); } #[test] fn direct_selection_drag_and_arrow_keys_move_placed_content() { use designcraft_tools::{Mods, PointerEvent, PointerKind, SnapView, ToolKey}; let mut s = session(); let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); s.execute("place.load", &json!({"base64": cmd::base64_encode(&png)})).unwrap(); s.execute("place.drop", &json!({"frame": f})).unwrap(); let xfs = |s: &Session| { let it = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone(); (it.xf, it.graphic().unwrap().xf.translation()) }; let (frame0, g0) = xfs(&s); let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) }; let at = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(150.0, 150.0)); s.set_tool("directSelection"); s.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 10.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, at.x + 30.0, at.y + 10.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, at.x + 30.0, at.y + 10.0), v).unwrap(); assert!(s.doc().unwrap().selection.content); let (frame1, g1) = xfs(&s); assert_eq!(frame1, frame0, "the frame stays"); assert_eq!(g1 - g0, designcraft_geom::Vec2::new(30.0, 10.0)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(xfs(&s).1, g0, "one undo step"); // Arrow keys nudge the selected content; Shift nudges ten times as far. s.set_tool("selection"); s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); s.tool_key(ToolKey::Down, Mods { shift: true, ..Default::default() }, v).unwrap(); let (frame2, g2) = xfs(&s); assert_eq!(frame2, frame0); assert_eq!(g2 - g0, designcraft_geom::Vec2::new(1.0, 10.0)); } #[test] fn preflight_notes_an_assumed_mojikumi_base_set_without_calling_the_table_unapplied() { let mut s = session(); s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "中文 A1"})).unwrap(); let story = s.doc().unwrap().doc.stories.keys().next().unwrap().0; let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); std::sync::Arc::make_mut(&mut d.styles) .mojikumi_tables .push(designcraft_doc::cjk::MojikumiTable { name: "Baseless".into(), ..Default::default() }); s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 1})).unwrap(); s.execute("type.para", &json!({"attrs": {"mojikumi": "MojikumiTable/Baseless"}})).unwrap(); let result = s.execute("preflight.run", &json!({})).unwrap(); let messages: Vec<_> = result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedTypography").filter_map(|i| i["message"].as_str()).collect(); assert!(messages.iter().any(|m| m.contains("Baseless") && m.contains("no base set")), "{messages:?}"); assert!(!messages.iter().any(|m| m.contains("not applied")), "{messages:?}"); } #[test] fn live_corner_gestures_are_one_undo_step_each() { use designcraft_geom::corners::CornerShape; use designcraft_tools::{Mods, PointerEvent, PointerKind}; let mut s = session(); s.execute("frame.create", &json!({"rect": [100, 100, 300, 250], "content": "graphic"})).unwrap(); let id = s.doc().unwrap().doc.spreads[0].items[0].id; s.execute("selection.set", &json!({"ids": [id.0]})).unwrap(); s.set_tool("selection"); let v = ViewInfo::at_zoom(1.0); let undo = |s: &Session| s.doc().unwrap().history.undo.len(); let corners = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().corners.corners; let before = undo(&s); // Click the widget, then drag the top-left diamond 20 pt toward the centre. s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 111.5), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 111.5), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Down, 112.0, 100.0), v).unwrap(); for x in [116.0, 124.0, 132.0] { s.pointer(&PointerEvent::new(PointerKind::Drag, x, 100.0), v).unwrap(); } s.pointer(&PointerEvent::new(PointerKind::Up, 132.0, 100.0), v).unwrap(); assert_eq!(undo(&s), before + 1); assert!(corners(&s).iter().all(|c| c.shape == CornerShape::Rounded && (c.size - 20.0).abs() < 1e-9), "{:?}", corners(&s)); // Alt-click the top-left diamond, now 20 pt along the edge. let alt = Mods { alt: true, ..Default::default() }; s.pointer(&PointerEvent::new(PointerKind::Down, 120.0, 100.0).with_mods(alt), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, 120.0, 100.0).with_mods(alt), v).unwrap(); assert_eq!(undo(&s), before + 2); assert_eq!(corners(&s)[0].shape, CornerShape::InverseRounded); assert_eq!(corners(&s)[1].shape, CornerShape::Rounded); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(corners(&s)[0].shape, CornerShape::Rounded); // Malformed per-corner parameters are errors, not silent defaults. let c = json!({"shape": "rounded", "size": 5}); assert!(s.execute("object.cornerOptions", &json!({"corners": [c, c, c]})).is_err()); assert!(s.execute("object.cornerOptions", &json!({"corners": [c, c, c, {"shape": "rounded", "size": -1}]})).is_err()); assert!(s.execute("object.cornerOptions", &json!({"corners": "rounded"})).is_err()); } #[test] fn alt_arrows_kern_at_the_caret_and_track_a_selection() { use designcraft_doc::{Kerning, StoryId}; use designcraft_tools::{Mods, ToolKey}; let mut s = session(); let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Hello world"})).unwrap(); let sid = r["story"].as_u64().unwrap(); s.set_tool("type"); let v = ViewInfo::at_zoom(1.0); let alt = Mods { alt: true, ..Default::default() }; let story = |s: &Session| s.doc().unwrap().doc.story(StoryId(sid)).unwrap().clone(); let caret = |s: &mut Session, at: usize| s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at})).unwrap(); // Caret between "e" and "l": Alt+Right kerns the pair by the increment; the caret stays. caret(&mut s, 2); assert!(s.tool_key(ToolKey::Right, alt, v).unwrap()); assert_eq!(story(&s).format_after(1).over.kerning, Some(Kerning::Manual(20.0))); assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, 2); s.tool_key(ToolKey::Right, alt, v).unwrap(); assert_eq!(story(&s).format_after(1).over.kerning, Some(Kerning::Manual(40.0))); // One undo step per press. s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(story(&s).format_after(1).over.kerning, Some(Kerning::Manual(20.0))); // Alt+Cmd (Alt+Ctrl elsewhere) steps five increments. caret(&mut s, 4); s.tool_key(ToolKey::Left, Mods { cmd: true, ..alt }, v).unwrap(); assert_eq!(story(&s).format_after(3).over.kerning, Some(Kerning::Manual(-100.0))); // The increment is the document's Kerning/Tracking preference. s.execute("document.preferences", &json!({"kerningIncrement": 10})).unwrap(); caret(&mut s, 2); s.tool_key(ToolKey::Right, alt, v).unwrap(); assert_eq!(story(&s).format_after(1).over.kerning, Some(Kerning::Manual(30.0))); // A selection: tracking of the selected text. s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap(); s.tool_key(ToolKey::Right, alt, v).unwrap(); let st = story(&s); assert_eq!((st.format_after(6).over.tracking, st.format_after(10).over.tracking), (Some(10.0), Some(10.0))); assert_eq!(st.format_after(4).over.tracking, None); s.tool_key(ToolKey::Left, Mods { cmd: true, ctrl: true, ..alt }, v).unwrap(); assert_eq!(story(&s).format_after(8).over.tracking, Some(-40.0)); // Words: Cmd+Right (Ctrl+Right elsewhere); Alt+Shift+Right extends by a word. caret(&mut s, 0); s.tool_key(ToolKey::Right, Mods { cmd: true, ..Default::default() }, v).unwrap(); assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, 5); caret(&mut s, 0); s.tool_key(ToolKey::Right, Mods { alt: true, shift: true, ..Default::default() }, v).unwrap(); let t = s.doc().unwrap().selection.text.unwrap(); assert_eq!((t.anchor, t.focus), (0, 5)); }