mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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]>
645 lines
34 KiB
Rust
645 lines
34 KiB
Rust
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);
|
||
}
|