Files
vectorcraft/crates/engine/src/cmd/threads/tests.rs
T
2026-11-08 23:50:32 +07:00

1520 lines
85 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
const TEXT: &str = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. ";
fn setup() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": {"width": 150, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
(s, a, b)
}
fn text(s: &Session, id: u64) -> String {
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("not text"),
}
}
#[test]
fn empty_later_frames_keep_the_first_frames_edited_paragraph_style() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let a =
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "First", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!([text(&s, a), text(&s, b), text(&s, c)], ["First", "", ""]);
s.execute("text.setFormat", &json!({"ids": [a], "hyphenate": true})).unwrap();
let hyphenates = |id| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.para_at(0).hyphenate,
_ => panic!("not text"),
};
assert_eq!([hyphenates(a), hyphenates(b), hyphenates(c)], [true, true, true]);
}
#[test]
fn a_restyled_empty_frame_keeps_its_style_before_an_unedited_empty_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "First\n", "area": {"width": 150, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!([text(&s, a), text(&s, b), text(&s, c)], ["First\n", "", ""]);
s.execute("text.setFormat", &json!({"ids": [b], "hyphenate": true})).unwrap();
let hyphenates = |id, para| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.para_at(para).hyphenate,
_ => panic!("not text"),
};
// The empty paragraph after the break takes the style; the paragraph "First" keeps its own.
assert_eq!([hyphenates(a, 0), hyphenates(a, 1), hyphenates(b, 0), hyphenates(c, 0)], [false, true, true, true]);
}
#[test]
fn thread_flows_reflows_on_edit_and_survives_delete() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
let (ta, tb) = (text(&s, a), text(&s, b));
assert!(!ta.is_empty() && !tb.is_empty());
assert_eq!(format!("{ta}{tb}"), TEXT.repeat(4));
// Editing the first frame re-flows into the second.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap();
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4)));
assert_ne!(text(&s, b), tb, "the overflow moved on");
// Deleting the first frame moves all its text into the second.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
assert_eq!(text(&s, b), format!("NEW {}", TEXT.repeat(4)));
assert!(s.doc().unwrap().doc.text_threads.is_empty(), "a one-frame thread is no thread");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.text_threads.len(), 1);
}
/// A file that lists a frame more than once, in its own thread or in another, flows the story
/// through each frame once: repeated places would copy its text again and again.
#[test]
fn frames_listed_twice_flow_the_story_once() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
let (fa, fb) = (NodeId(a), NodeId(b));
d.text_threads = vec![vec![fa, fb, fa, fb, fa], vec![fb, fa], vec![fa, fb]];
reflow(&Document::new(1.0, 1.0), &mut d);
assert_eq!(d.text_threads, vec![vec![fa, fb]]);
let plain = |id: NodeId| match &d.node(id).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("not text"),
};
assert_eq!(format!("{}{}", plain(fa), plain(fb)), TEXT.repeat(4));
}
/// A frame left alone when a repeated listing is dropped (`[[a, b], [b, c]]` → `[[a, b], [c]]`)
/// is flowed again as a story of its own, even when no frame changed: it no longer continues a
/// paragraph from the frame before it.
#[test]
fn a_frame_left_alone_by_a_dropped_repeat_stops_continuing() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
let c =
s.execute("text.create", &json!({"x": 400, "y": 10, "text": "Tail", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
let (fa, fb, fc) = (NodeId(a), NodeId(b), NodeId(c));
d.text_threads = vec![vec![fa, fb], vec![fb, fc]];
if let Some(NodeKind::Text(t)) = d.node_mut(fc).map(|n| &mut n.kind) {
t.first_para_continues = true;
}
let before = d.clone();
reflow(&before, &mut d);
assert_eq!(d.text_threads, vec![vec![fa, fb]]);
let NodeKind::Text(t) = &d.node(fc).unwrap().kind else { panic!("not text") };
assert!(!t.first_para_continues, "an unthreaded frame continues nothing");
assert_eq!(t.plain_text(), "Tail");
}
#[test]
fn edits_copy_only_the_stories_of_threads_whose_frames_changed() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
let c = s.execute("text.create", &json!({"x": 10, "y": 200, "text": TEXT.repeat(2), "area": {"width": 150, "height": 40}})).unwrap()["id"]
.as_u64()
.unwrap();
let d = s.execute("shape.rectangle", &json!({"x": 200, "y": 220, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [c, d]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
let texts = |s: &Session| [a, b, c, d].map(|id| text(s, id));
let copied = || COPIED.with(std::cell::Cell::get);
let (shown, start) = (texts(&s), copied());
// A rectangle drawn, moved and dragged beside the threads changes none of their frames.
let r = s.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.begin_interaction("Move").unwrap();
for dx in 1..=3 {
s.preview("object.move", &json!({"dx": dx, "dy": 0})).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(copied(), start, "edits beside the threads copied their stories");
assert_eq!(texts(&s), shown);
// Typing in the first thread copies and re-flows that thread's story only.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap();
assert_eq!(copied(), start + 1);
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4)));
assert_eq!(texts(&s)[2..], shown[2..]);
let ids = |t: [u64; 2]| t.map(NodeId).to_vec();
assert_eq!(s.doc().unwrap().doc.text_threads, [ids([a, b]), ids([c, d])]);
}
/// Time edits of a story of 50,000 characters threaded through 8 frames, each frame wrapping
/// around an ellipse, with one style, a style change every 10 words and a style change at every
/// word: 21 characters typed into the first frame, then 200 moves of a rectangle below the
/// frames and 200 frames of a drag of it.
/// Run with `cargo test --release -p vectorcraft-engine --lib threaded_story_edit_timing -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn threaded_story_edit_timing() {
fn median(s: &mut Session, n: u32, f: &dyn Fn(&mut Session, f64)) -> f64 {
let mut ms: Vec<f64> = (1..=n)
.map(|i| {
let start = std::time::Instant::now();
f(s, f64::from(i));
start.elapsed().as_secs_f64() * 1000.0
})
.collect();
ms.sort_by(f64::total_cmp);
ms[ms.len() / 2]
}
let story = format!("{}\n", TEXT.repeat(6).trim_end()).repeat(81)[..50_000].to_string();
for words in [0, 10, 1] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 4400, "height": 1200})).unwrap();
let area = json!({"width": 500, "height": 900});
let first = s.execute("text.create", &json!({"x": 20, "y": 20, "text": story, "size": 9, "area": area})).unwrap()["id"].as_u64().unwrap();
if words > 0 {
s.edit("Restyle", |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(first)).map(|n| &mut n.kind) else { panic!("not text") };
let style = t.runs[0].style.clone();
let pieces: Vec<&str> = story.split_inclusive([' ', '\n']).collect();
t.runs = pieces
.chunks(words)
.enumerate()
.map(|(i, c)| {
let mut run = TextRun::new(c.concat(), style.clone());
run.style.underline = i % 2 == 1;
run
})
.collect();
refresh_bounds(t);
Ok(())
})
.unwrap();
}
let mut ids = vec![first];
for k in 1..8 {
let frame = json!({"x": 20 + k * 540, "y": 20, "width": 500, "height": 900});
ids.push(s.execute("shape.rectangle", &frame).unwrap()["id"].as_u64().unwrap());
}
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
for k in 0..8 {
let e = s.execute("shape.ellipse", &json!({"x": 195 + k * 540, "y": 395, "width": 150, "height": 150})).unwrap()["id"].clone();
s.execute("object.textWrap.make", &json!({"ids": [e]})).unwrap();
}
let r = s.execute("shape.rectangle", &json!({"x": 20, "y": 1100, "width": 30, "height": 30})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let typing = median(&mut s, 21, &|s, _| {
s.execute("text.editRange", &json!({"id": first, "start": 0, "end": 0, "insert": "x"})).unwrap();
});
let edit = median(&mut s, 200, &|s, _| {
s.execute("object.move", &json!({"dx": 0.5, "dy": 0})).unwrap();
});
s.begin_interaction("Move").unwrap();
let frame = median(&mut s, 200, &|s, i| {
s.preview("object.move", &json!({"dx": i * 0.25, "dy": 0})).unwrap();
});
s.commit_interaction().unwrap();
let d = &s.doc().unwrap().doc;
let runs: usize = ids.iter().filter_map(|id| text_of(d, NodeId(*id))).map(|t| t.runs.len()).sum();
println!("{runs} runs in 8 frames: typing {typing:.1} ms, a move below them {edit:.3} ms, a drag frame {frame:.3} ms (medians)");
}
}
fn new_id(v: Value) -> u64 {
v["id"].as_u64().unwrap()
}
fn threads(s: &Session) -> Vec<Vec<u64>> {
s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect()
}
/// Bounds of area type `id`'s frame on the page.
fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
t.xf.transform_rect_bbox(frame.bounds().unwrap())
}
/// #2572: the text loaded from an out port threads into a closed path, into a new frame of the
/// first one's shape and size at a point, and into a new rectangle, each in one undo step.
#[test]
fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() {
let (mut s, a, b) = setup();
assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b);
assert_eq!(threads(&s), [[a, b]]);
assert!(!text(&s, b).is_empty(), "the overflow flowed into the path");
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back");
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap());
assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click");
let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap());
assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0));
assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from");
assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4));
assert!(!text(&s, d).is_empty());
// A frame threaded from the middle of a thread goes in after it.
let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap());
assert_eq!(threads(&s), [[a, e, c, d]]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none());
assert_eq!(threads(&s), [[a, c, d]]);
}
/// #2572: the text loaded from an in port threads back: the new frame or the path clicked comes
/// before the frame it was loaded from, so the story starts there and flows on into it; in the
/// middle of a thread the new frame goes in between, each in one undo step.
#[test]
fn add_before_threads_the_story_from_the_new_frame_into_the_clicked_one() {
let (mut s, a, b) = setup();
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200], "before": true})).unwrap());
assert_eq!(threads(&s), [[c, a]]);
assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the clicked frame's size at the click");
assert!(TEXT.repeat(4).starts_with(&text(&s, c)) && !text(&s, c).is_empty(), "the story starts in the new frame: {:?}", text(&s, c));
assert_eq!(format!("{}{}", text(&s, c), text(&s, a)), TEXT.repeat(4));
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(c)]);
// In between the frame and the one before it.
let d = new_id(s.execute("text.thread.add", &json!({"from": a, "rect": [10, 100, 300, 150], "before": true})).unwrap());
assert_eq!(threads(&s), [[c, d, a]]);
assert_eq!(format!("{}{}{}", text(&s, c), text(&s, d), text(&s, a)), TEXT.repeat(4));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [[c, a]]);
// A closed path clicked with the in port's text comes first, with the start of the story.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b, "before": true})).unwrap()), b);
assert_eq!(threads(&s), [[b, a]]);
assert!(TEXT.repeat(4).starts_with(&text(&s, b)) && !text(&s, b).is_empty());
assert_eq!(format!("{}{}", text(&s, b), text(&s, a)), TEXT.repeat(4));
// `before: false` is the out port's.
let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0], "before": false})).unwrap());
assert_eq!(threads(&s), [[b, a, e]]);
}
/// Type on a path along a line beside the area type of [`setup`].
fn type_on_a_line(s: &mut Session) -> u64 {
let line = new_id(s.execute("shape.line", &json!({"x1": 10, "y1": 250, "x2": 110, "y2": 250})).unwrap());
new_id(s.execute("text.createInPath", &json!({"path": line, "mode": "onPath", "text": ""})).unwrap())
}
/// Type on a path threads as area type does, in any mix: a story flows from area type along a
/// path and on into area type, and a new frame threaded from type on a path at a point is type
/// on a path of the same shape.
#[test]
fn type_on_a_path_threads_with_area_type() {
let (mut s, a, b) = setup();
let on_path = type_on_a_line(&mut s);
s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap();
s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap();
assert_eq!(threads(&s), [[a, on_path, b]]);
let shares = [a, on_path, b].map(|id| text(&s, id));
assert!(shares.iter().all(|t| !t.is_empty()), "each frame takes a share: {shares:?}");
assert_eq!(shares.concat(), TEXT.repeat(4));
let c = new_id(s.execute("text.thread.add", &json!({"from": on_path, "at": [300, 250]})).unwrap());
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind else { panic!("not text") };
assert!(matches!(t.kind, TextKind::OnPath { .. }), "type on a path, as the frame it was threaded from");
assert_eq!(threads(&s), [[a, on_path, c, b]]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [[a, on_path, b]]);
}
/// Type on a path released from its thread gives its text back to the frame before it.
#[test]
fn type_on_a_path_is_released_from_its_thread() {
let (mut s, a, _) = setup();
let on_path = type_on_a_line(&mut s);
s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap();
assert!(!text(&s, on_path).is_empty());
s.execute("select.set", &json!({"ids": [on_path]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert!(text(&s, on_path).is_empty());
assert_eq!(text(&s, a), TEXT.repeat(4));
}
/// Area type threaded into from an out port takes the overflow at once, before its own text.
#[test]
fn add_into_area_type_flows_the_story_into_it() {
let (mut s, a, _) = setup();
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
assert_eq!(threads(&s), [[a, c]]);
assert_eq!(format!("{}{}", text(&s, a), text(&s, c)), format!("{}Own text.", TEXT.repeat(4)));
assert!(text(&s, c).len() > "Own text.".len(), "the overflow flowed into the frame: {:?}", text(&s, c));
}
#[test]
fn add_refuses_bad_params_and_leaves_the_document_alone() {
let (mut s, a, b) = setup();
let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap());
let before = s.doc().unwrap().doc.clone();
for p in [
json!({"to": b}),
json!({"from": b, "at": [0, 0]}),
json!({"from": 9999, "at": [0, 0]}),
json!({"from": -1, "at": [0, 0]}),
json!({"from": a}),
json!({"from": a, "to": b, "at": [0, 0]}),
json!({"from": a, "to": a}),
json!({"from": a, "to": line}),
json!({"from": a, "to": 9999}),
json!({"from": a, "at": [0]}),
json!({"from": a, "at": ["1", 2]}),
json!({"from": a, "rect": [0, 0, -10, 10]}),
json!({"from": a, "rect": [0, 0, 10, 0]}),
json!({"from": a, "rect": [0, 0, 10]}),
json!({"from": a, "rect": "big"}),
json!({"from": a, "at": [0, 0], "before": "yes"}),
json!({"from": a, "at": [0, 0], "before": 1}),
] {
assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document");
}
s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap();
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap());
assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded");
}
/// A frame released from a thread that keeps two or more frames gives its text back to them:
/// the story flows through them once.
#[test]
fn release_from_a_longer_thread_keeps_the_story_once() {
let (mut s, a, b) = setup();
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap();
assert_eq!(threads(&s), [[a, c, b]]);
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert_eq!(threads(&s), [[a, b]]);
assert!(text(&s, c).is_empty());
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
}
/// A thread of `n` frames (2 or 3), each showing part of the story: area type `a` of [`setup`],
/// for 3 an area type frame that is empty until it is threaded, then the rectangle `b`.
fn thread_of(n: usize) -> (Session, Vec<u64>) {
let (mut s, a, b) = setup();
let frames = if n == 3 {
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap();
vec![a, c, b]
} else {
s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap();
vec![a, b]
};
assert_eq!(threads(&s), std::slice::from_ref(&frames));
assert!(frames.iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story");
(s, frames)
}
/// Convert To Point Type on a frame of threaded text takes it out of its thread with the text
/// it holds, and the other frames keep the text they hold: the story holds each character once.
/// A thread left with one frame is no thread. Undo threads the frame again.
#[test]
fn a_frame_converted_to_point_type_leaves_its_thread_with_its_text() {
// Soft wraps of the point type become line breaks, so its characters are compared without
// white space.
let squeeze = |t: &str| t.split_whitespace().collect::<String>();
for n in [2, 3] {
for k in 0..n {
let (mut s, frames) = thread_of(n);
let shown: Vec<String> = frames.iter().map(|id| text(&s, *id)).collect();
let id = frames[k];
s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap();
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("not text") };
assert!(matches!(t.kind, TextKind::Point), "{n} frames, frame {k}: point type");
assert_eq!(squeeze(&text(&s, id)), squeeze(&shown[k]), "{n} frames, frame {k}: the point type keeps its text");
for (f, before) in frames.iter().zip(&shown).filter(|(f, _)| **f != id) {
assert_eq!(text(&s, *f), *before, "{n} frames, frame {k}: frame {f} keeps its text");
}
let rest: Vec<u64> = frames.iter().copied().filter(|f| *f != id).collect();
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{n} frames, frame {k}");
let all: String = frames.iter().map(|f| text(&s, *f)).collect();
assert_eq!(squeeze(&all), squeeze(&TEXT.repeat(4)), "{n} frames, frame {k}: each character once");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), std::slice::from_ref(&frames));
assert_eq!(frames.iter().map(|f| text(&s, *f)).collect::<Vec<_>>(), shown, "{n} frames, frame {k}: undo");
}
}
}
#[test]
fn release_and_remove() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
let tb = text(&s, b);
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert_eq!(text(&s, b), tb, "remove keeps what each frame shows");
assert!(s.doc().unwrap().doc.text_threads.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert_eq!(text(&s, b), "");
assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame");
}
/// `story` threaded through three area type frames, of 150 by 60, 40 and `last` points, each
/// showing part of it.
fn three_frames(story: &str, last: f64) -> (Session, [u64; 3]) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let frame = |s: &mut Session, x: f64, y: f64, h: f64, text: &str| {
new_id(s.execute("text.create", &json!({"x": x, "y": y, "text": text, "area": {"width": 150, "height": h}})).unwrap())
};
let a = frame(&mut s, 10.0, 10.0, 60.0, story);
let b = frame(&mut s, 10.0, 100.0, 40.0, "");
let c = frame(&mut s, 200.0, 10.0, last, "");
s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap();
s.execute("text.thread.add", &json!({"from": b, "to": c})).unwrap();
assert_eq!(threads(&s), [[a, b, c]]);
assert!([a, b, c].iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story");
(s, [a, b, c])
}
fn continues(s: &Session, id: u64) -> bool {
text_of(&s.doc().unwrap().doc, NodeId(id)).is_some_and(|t| t.first_para_continues)
}
/// A double-click on a threaded port breaks the thread there: the story flows into the frames
/// before the break and the frames after it are left empty, still threaded with each other
/// when two or more. One undo step restores the thread and its text.
#[test]
fn break_splits_the_thread_and_the_story_flows_into_the_first_part() {
let story = TEXT.repeat(12);
let (mut s, [a, b, c]) = three_frames(&story, 40.0);
let d = new_id(s.execute("text.create", &json!({"x": 200, "y": 100, "text": "", "area": {"width": 150, "height": 100}})).unwrap());
s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap();
let all = [a, b, c, d];
let shown: Vec<String> = all.iter().map(|id| text(&s, *id)).collect();
assert!(shown.iter().all(|t| !t.is_empty()), "each frame shows part of the story");
let joined = |s: &Session, ids: &[u64]| ids.iter().map(|id| text(s, *id)).collect::<String>();
// (frame, port, threads after, the frames that hold the story, the frames left empty)
let cases = [
(a, "out", vec![vec![b, c, d]], vec![a], vec![b, c, d]),
(b, "in", vec![vec![b, c, d]], vec![a], vec![b, c, d]),
(b, "out", vec![vec![a, b], vec![c, d]], vec![a, b], vec![c, d]),
(d, "in", vec![vec![a, b, c]], vec![a, b, c], vec![d]),
(c, "out", vec![vec![a, b, c]], vec![a, b, c], vec![d]),
];
for (id, port, after, full, empty) in cases {
let case = format!("{port} port of {id}");
s.execute("text.thread.break", &json!({"id": id, "port": port})).unwrap();
assert_eq!(threads(&s), after, "{case}");
assert_eq!(joined(&s, &full), story, "{case}: the story flows into the first part");
assert!(empty.iter().all(|id| text(&s, *id).is_empty()), "{case}: the frames after the break are empty");
assert!(!continues(&s, empty[0]), "{case}: the first frame after the break starts a paragraph");
assert!(!continues(&s, a), "{case}: the first frame starts the story");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [all.to_vec()], "{case}: undo");
assert_eq!(all.iter().map(|id| text(&s, *id)).collect::<Vec<_>>(), shown, "{case}: undo");
}
}
#[test]
fn break_refuses_a_port_with_no_thread_and_bad_params() {
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0);
let lone = new_id(s.execute("text.create", &json!({"x": 400, "y": 10, "text": "Alone.", "area": {"width": 100, "height": 100}})).unwrap());
let ids = [a, b, c, lone];
let shown: Vec<String> = ids.iter().map(|id| text(&s, *id)).collect();
let before = threads(&s);
for params in [
json!({"id": a, "port": "in"}),
json!({"id": c, "port": "out"}),
json!({"id": lone, "port": "out"}),
json!({"id": lone, "port": "in"}),
json!({"id": a, "port": "up"}),
json!({"id": a}),
json!({"port": "out"}),
json!({"id": 9999, "port": "out"}),
] {
assert!(s.execute("text.thread.break", &params).is_err(), "{params}");
}
assert_eq!(threads(&s), before);
assert_eq!(ids.iter().map(|id| text(&s, *id)).collect::<Vec<_>>(), shown);
}
/// Whether the document holds area type or type on a path `id`.
fn is_frame(s: &Session, id: u64) -> bool {
text_of(&s.doc().unwrap().doc, NodeId(id)).is_some()
}
/// Create Outlines and Object › Expand on frames of threaded text take them out of their
/// thread with the text they show, which their outlines draw, and the other frames keep the
/// text they hold: each character is in the document once. A thread left with one frame is no
/// thread. Undo threads the frames again.
#[test]
fn outlined_frames_leave_their_thread_with_their_text() {
for command in ["type.createOutlines", "object.expand"] {
for out in [&[0][..], &[1], &[2], &[0, 2]] {
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
let shown = frames.map(|id| text(&s, id));
assert_eq!(shown.concat(), TEXT.repeat(4));
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute(command, &json!({})).unwrap();
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
for id in &ids {
assert!(!is_frame(&s, *id), "{command} {out:?}: frame {id} is outlined");
}
let st = s.doc().unwrap();
let groups = st.selection.objects.iter().filter(|g| matches!(st.doc.node(**g).map(|n| &n.kind), Some(NodeKind::Group { .. })));
assert_eq!(groups.count(), ids.len(), "{command} {out:?}: the outlines are selected");
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
assert_eq!(text(&s, *id), *before, "{command} {out:?}: frame {id} keeps its text");
}
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{command} {out:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [frames], "{command} {out:?}: undo");
assert_eq!(frames.map(|id| text(&s, id)), shown, "{command} {out:?}: undo");
}
}
}
/// Expand Appearance outlines a frame with an effect, and the frame leaves its thread with the
/// text it shows; Object › Expand of such a frame expands its appearance first.
#[test]
fn a_frame_whose_appearance_expands_leaves_its_thread_with_its_text() {
for command in ["effect.expandAppearance", "object.expand"] {
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0);
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [b]})).unwrap();
let shown = [a, b, c].map(|id| text(&s, id));
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute(command, &json!({})).unwrap();
assert!(!is_frame(&s, b), "{command}: the frame is outlined");
assert_eq!((text(&s, a), text(&s, c)), (shown[0].clone(), shown[2].clone()), "{command}: the other frames keep their text");
assert_eq!(threads(&s), [[a, c]], "{command}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [[a, b, c]], "{command}: undo");
assert_eq!([a, b, c].map(|id| text(&s, id)), shown, "{command}: undo");
}
}
/// Object › Flatten Transparency outlines frames of threaded text with Convert All Text to
/// Outlines, and turns transparent frames into flat regions or an image: each such frame leaves
/// its thread with the text it shows, and the other frames keep the text they hold. Undo threads
/// the frames again. When the last frame is flattened, the frame before it takes the text it has
/// no room for.
#[test]
fn flattened_frames_leave_their_thread_with_their_text() {
let cases = [(json!({"textToOutlines": true}), false), (json!({}), true), (json!({"balance": 0}), true)];
for (options, transparent) in cases {
for out in [&[0][..], &[1], &[2], &[0, 2]] {
let case = format!("{options} {out:?}");
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
if transparent {
s.execute("transparency.set", &json!({"ids": ids, "opacity": 50})).unwrap();
}
let shown = frames.map(|id| text(&s, id));
assert_eq!(shown.concat(), TEXT.repeat(4));
let mut p = options.clone();
p["ids"] = json!(ids);
s.execute("object.flattenTransparency", &p).unwrap();
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
for id in &ids {
assert!(!is_frame(&s, *id), "{case}: frame {id} is flattened");
}
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text");
}
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [frames], "{case}: undo");
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
}
}
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
let shown = [a, b, c].map(|id| text(&s, id));
let laid = {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
};
assert!(laid.overflow, "the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let hidden = shown[2][end..].trim_start_matches(' ');
s.execute("object.flattenTransparency", &json!({"ids": [c], "textToOutlines": true})).unwrap();
assert_eq!(text(&s, a), shown[0]);
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest");
assert_eq!(threads(&s), [[a, b]]);
}
/// Expand Appearance keeps type whose only effect is a shadow or Crop Marks as type under a new
/// id, in a group under the frame's id: the type takes the frame's place in its thread, and
/// each frame keeps its text, the last frame the text it has no room for too. Type in a group
/// with Crop Marks keeps its id and stays in its thread.
#[test]
fn type_that_expand_appearance_keeps_takes_its_frames_place() {
for (effect, grouped) in [("stylize.dropShadow", false), ("cropMarks", false), ("cropMarks", true)] {
for k in [1, 2] {
let case = format!("{effect} grouped {grouped} frame {k}");
let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0);
let id = frames[k];
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let target = if grouped { new_id(s.execute("object.group", &json!({})).unwrap()) } else { id };
s.execute("effect.apply", &json!({"effect": effect, "item": null, "ids": [target]})).unwrap();
let shown = frames.map(|id| text(&s, id));
s.execute("select.set", &json!({"ids": [target]})).unwrap();
s.execute("effect.expandAppearance", &json!({})).unwrap();
let mut types = vec![];
if let Some(n) = s.doc().unwrap().doc.node(NodeId(target)) {
n.walk(&mut |m| {
if matches!(m.kind, NodeKind::Text(_)) {
types.push(m.id.0);
}
});
}
let [kept] = types[..] else { panic!("{case}: the type is kept: {types:?}") };
assert_eq!(kept == id, grouped, "{case}: the type has a new id unless it was in a group");
let mut now = frames;
now[k] = kept;
assert_eq!(threads(&s), [now], "{case}: the type takes the frame's place");
assert_eq!(now.map(|id| text(&s, id)), shown, "{case}: each frame keeps its text");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [frames], "{case}: undo");
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
}
}
}
/// The text that does not fit a thread's last frame, which its outlines do not draw, stays in
/// the story: when the last frame is outlined, the frame before it takes that text after its
/// own.
#[test]
fn outlining_the_last_frame_keeps_the_text_it_has_no_room_for() {
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
let shown = [a, b, c].map(|id| text(&s, id));
let laid = {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
};
assert!(laid.overflow, "the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let hidden = shown[2][end..].trim_start_matches(' ');
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
assert_eq!(text(&s, a), shown[0]);
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest");
assert_eq!(threads(&s), [[a, b]]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!([a, b, c].map(|id| text(&s, id)), shown);
}
/// Object › Rasterize on frames of threaded text takes them out of their thread with the text
/// they show, which the image draws, and the other frames keep the text they hold. Undo threads
/// the frames again. When the last frame is rasterized, the frame before it takes the text it
/// has no room for.
#[test]
fn rasterized_frames_leave_their_thread_with_their_text() {
for clip in [false, true] {
for out in [&[0][..], &[1], &[2], &[0, 2]] {
let case = format!("clippingMask {clip} {out:?}");
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
let shown = frames.map(|id| text(&s, id));
assert_eq!(shown.concat(), TEXT.repeat(4));
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 36, "clippingMask": clip})).unwrap();
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
for id in &ids {
assert!(!is_frame(&s, *id), "{case}: frame {id} is rasterized");
}
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text");
}
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [frames], "{case}: undo");
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
}
}
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
let shown = [a, b, c].map(|id| text(&s, id));
let laid = {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
};
assert!(laid.overflow, "the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let hidden = shown[2][end..].trim_start_matches(' ');
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 36})).unwrap();
assert_eq!(text(&s, a), shown[0]);
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest");
assert_eq!(threads(&s), [[a, b]]);
}
/// Frame `id`'s first line: whether its first paragraph continues from the frame before, where
/// its text starts from the frame's left edge, and how many list markers it starts with.
fn first_line(s: &Session, id: u64) -> (bool, f64, usize) {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let l = &lay.lines[0];
let x = lay.glyphs[l.glyph_start..l.glyph_end].iter().find(|g| g.len > 0).map_or(f64::NAN, |g| g.origin.x);
let markers = lay.markers.iter().filter(|m| lay.glyphs[m.glyphs.start].line == 0).count();
(t.first_para_continues, (x - frame.bounds().unwrap().x0 - 1e-9).round(), markers)
}
/// A paragraph's first-line indent and bullet belong to its first line, in the frame it starts
/// in: the frame it continues into starts at its left indent, without the bullet. Re-flowing,
/// saving and opening keep it. In a frame that is no longer threaded and in a copy of a frame,
/// the first paragraph has its first-line indent and bullet.
#[test]
fn a_paragraph_continued_into_the_next_frame_starts_it_without_its_first_line_indent_or_bullet() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "bullet"})).unwrap();
assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 18.0, 1), (true, 18.0, 0)));
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "none", "leftIndent": 0, "firstLineIndent": 24})).unwrap();
assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0)));
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
// Typing re-flows the story and keeps it.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap();
assert_eq!(first_line(&s, b), (true, 0.0, 0));
// Saved and opened: a native file re-flows its threads, a restored document keeps its text.
for name in ["thread.vectorcraft", "thread.eps"] {
let bytes = if name.ends_with(".eps") {
let r = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
} else {
vectorcraft_format::save(&s.doc().unwrap().doc, false)
};
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
assert_eq!(threads(&s), [[a, b]], "{name}");
assert_eq!((first_line(&s, a), first_line(&s, b)), ((false, 24.0, 0), (true, 0.0, 0)), "{name}");
}
// A copy of the second frame is in no thread, and starts with the indent.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let copy = s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
assert_eq!((first_line(&s, b), first_line(&s, copy)), ((true, 0.0, 0), (false, 24.0, 0)));
// …and so is a pasted one, and each copy's cached bounds and baselines are its own layout's.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let pasted = s.execute("edit.paste", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
let dragged = s.execute("layer.move", &json!({"ids": [b], "target": a, "place": "above", "copy": true})).unwrap()["ids"][0].as_u64().unwrap();
assert_eq!((first_line(&s, pasted), first_line(&s, dragged)), ((false, 24.0, 0), (false, 24.0, 0)));
// Every text object in the document has the cached bounds and baselines of its own layout.
let own_caches = |s: &Session, what: &str| {
let mut texts = vec![];
s.doc().unwrap().doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
texts.push((n.id, t));
}
});
for (id, t) in texts {
let mut fresh = (**t).clone();
refresh_bounds(&mut fresh);
assert!(t.cached_bounds.is_some() && t.cached_bounds == fresh.cached_bounds, "{what}: {id:?}");
assert_eq!(t.cached_baselines, fresh.cached_baselines, "{what}: {id:?}");
}
};
own_caches(&s, "copies");
// Release to Layers (Build), Release Opacity Mask, Edit Opacity Mask and Draw Inside copy the
// frame too.
let layer = s.doc().unwrap().doc.layer_containing(NodeId(b)).unwrap().0;
s.execute("layer.releaseToLayers", &json!({"id": layer, "build": true})).unwrap();
own_caches(&s, "Release to Layers (Build)");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({"ids": [a, b]})).unwrap();
s.execute("transparency.releaseOpacityMask", &json!({})).unwrap();
own_caches(&s, "Release Opacity Mask");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("transparency.editOpacityMask", &json!({"id": a})).unwrap();
own_caches(&s, "Edit Opacity Mask");
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 210, "y": 20, "width": 20, "height": 20})).unwrap();
own_caches(&s, "Draw Inside");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap();
assert_eq!((threads(&s), first_line(&s, b)), (vec![vec![a, b]], (true, 0.0, 0)));
// Remove Threading: each frame keeps its text, and its first paragraph starts in it.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert_eq!(first_line(&s, b), (false, 24.0, 0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(first_line(&s, b), (true, 0.0, 0));
// Released, the frame is empty and starts the next text typed into it as a paragraph.
let continues = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.first_para_continues,
_ => panic!("not text"),
};
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert!(text(&s, b).is_empty() && !continues(&s, b));
s.execute("edit.undo", &json!({})).unwrap();
// With the first frame released, the second one holds the whole story and starts it.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert!(threads(&s).is_empty() && text(&s, a).is_empty());
assert_eq!(first_line(&s, b), (false, 24.0, 0));
s.execute("edit.undo", &json!({})).unwrap();
// Converted to point type, it is in no thread, and its first line starts at the indent where
// the frame's left edge was.
let left = frame_box(&s, b).x0;
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("type.convertToPointType", &json!({})).unwrap();
assert!(!continues(&s, b));
{
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind else { panic!("not text") };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let g = lay.glyphs.iter().find(|g| g.len > 0).unwrap();
let x = (t.xf * g.origin).x - left;
assert!((x - 24.0).abs() < 1e-6, "{x}");
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(first_line(&s, b), (true, 0.0, 0));
// Text that ends the first frame with a paragraph break: the second frame starts a
// paragraph, with its indent.
let all = text(&s, a).len();
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": all, "insert": "Short line.\n".repeat(12)})).unwrap();
assert!(text(&s, a).ends_with('\n'), "{:?}", text(&s, a));
assert_eq!(first_line(&s, b), (false, 24.0, 0));
}
/// When Create Outlines takes frames out of a thread, a frame that now starts its thread or is
/// in no thread starts its paragraph with the first-line indent; a frame after one that ends
/// inside the paragraph still continues it. The frames that stay hold each character they held
/// once, and keep their text when no mark changes. Undo marks the frames again.
#[test]
fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() {
for (out, want) in [
(&[0][..], [None, Some(false), Some(true)]),
(&[0, 2], [None, Some(false), None]),
(&[1], [Some(false), None, Some(true)]),
(&[2], [Some(false), Some(true), None]),
] {
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
s.execute("text.setFormat", &json!({"ids": frames, "leftIndent": 0, "firstLineIndent": 24})).unwrap();
let before = frames.map(|id| first_line(&s, id));
assert_eq!(before, [(false, 24.0, 0), (true, 0.0, 0), (true, 0.0, 0)], "{out:?}");
let shown = frames.map(|id| text(&s, id));
assert_eq!(shown.concat(), TEXT.repeat(4), "{out:?}: the last frame has room for the rest");
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
let stay: Vec<usize> = (0..3).filter(|k| want[*k].is_some()).collect();
for k in &stay {
let on = want[*k] == Some(true);
let indent = if on { 0.0 } else { 24.0 };
assert_eq!(first_line(&s, frames[*k]), (on, indent, 0), "{out:?}: frame {k}");
}
let held: String = stay.iter().map(|k| shown[*k].as_str()).collect();
assert_eq!(stay.iter().map(|k| text(&s, frames[*k])).collect::<String>(), held, "{out:?}: each character once");
if stay.iter().all(|k| want[*k] == Some(before[*k].0)) {
for k in &stay {
assert_eq!(text(&s, frames[*k]), shown[*k], "{out:?}: frame {k} keeps its text");
}
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames.map(|id| first_line(&s, id)), before, "{out:?}: undo");
}
}
/// The text that a thread's last frame has no room for is measured as the frame lays it out,
/// without the first-line indent of the paragraph it continues: when the frame is outlined, the
/// frame before it takes only that text.
#[test]
fn outlining_a_continued_last_frame_keeps_only_the_text_it_has_no_room_for() {
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
s.execute("text.setFormat", &json!({"ids": [a, b, c], "leftIndent": 0, "firstLineIndent": 72})).unwrap();
assert_eq!(first_line(&s, c), (true, 0.0, 0));
let shown = [a, b, c].map(|id| text(&s, id));
let laid = {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
};
assert!(laid.overflow, "the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let hidden = shown[2][end..].trim_start_matches(' ');
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]));
}
/// The text that a thread's last frame has no room for is measured with the frame's numbered
/// paragraphs counted on from the frames before it: when the frame is outlined, the frame before
/// it takes only that text.
#[test]
fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
let story = format!("{items}{}", TEXT.repeat(4));
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
// A hanging indent that "I." fits and "VIII." does not: the text after "VIII." starts at
// the next tab stop.
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "leftIndent": 14, "firstLineIndent": -14})).unwrap();
let shown = [a, c].map(|id| text(&s, id));
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}");
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
assert_eq!(t.list_count, 7);
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII."));
assert!(laid.overflow, "the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_count: 0, ..t });
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text");
let hidden = shown[1][end..].trim_start_matches(' ');
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
}
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
fn quoted_thread() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let story = "“Quoted.”\n".repeat(20);
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert!(text(&s, b).starts_with('“'));
(s, a, b)
}
/// Frame `id`'s Optical Margin Alignment setting, and whether its glyphs start left of its frame
/// (its lines' opening quotation marks hang).
fn optical(s: &Session, id: u64) -> (bool, bool) {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
(t.optical_margin, t.cached_bounds.unwrap().x0 < frame.bounds().unwrap().x0 - 1.0)
}
/// Optical Margin Alignment is a setting of the whole thread: Type › Optical Margin Alignment
/// in any frame sets it in every frame of the thread, in one undo step, and the lines in every
/// frame hang. Typing keeps it, and frames taken out of the thread keep it.
#[test]
fn optical_margin_alignment_is_set_for_every_frame_of_a_thread() {
let (mut s, a, b) = quoted_thread();
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("type.opticalMarginAlignment", &json!({})).unwrap();
assert_eq!(r, json!({"on": true, "ids": [a, b]}));
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)), "the second frame's lines hang too");
// Turned off from the second frame, it is off in both, and one undo turns it back on in both.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false);
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
// Typing in the first frame re-flows the story and keeps the setting.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "“New.”\n"})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
// Any edit that sets one frame sets the thread.
s.edit("Set", |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") };
t.optical_margin = false;
refresh_bounds(t);
Ok(())
})
.unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("edit.undo", &json!({})).unwrap();
// Released or unthreaded, each frame keeps it.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, true));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
}
/// Threading type into a thread gives the joined thread the setting of its first frame.
#[test]
fn a_thread_takes_its_first_frames_optical_margin_alignment() {
let (mut s, a, b) = quoted_thread();
s.execute("text.thread.remove", &json!({})).unwrap();
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
// Threaded again in stacking order: the second frame takes the first one's setting.
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
// From `b`'s out port into `a`: `a` joins `b`'s thread after it and takes its setting.
s.execute("text.thread.add", &json!({"from": b, "to": a})).unwrap();
assert_eq!(threads(&s), [[b, a]]);
assert_eq!((optical(&s, a).0, optical(&s, b).0), (false, false));
// A new frame threaded from a frame takes the thread's setting.
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 10]})).unwrap());
assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]);
}
/// A file saved with the frames of one thread set differently opens with the first frame's
/// setting in every frame: a native file, and an EPS file restored from the document it carries.
#[test]
fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() {
let (mut s, a, b) = quoted_thread();
for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] {
for (id, on) in [(a, first), (b, !first)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.optical_margin = on;
}
let bytes = if name.ends_with(".eps") {
let r = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
} else {
vectorcraft_format::save(&s.doc().unwrap().doc, false)
};
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
assert_eq!(threads(&s), [[a, b]], "{name}");
assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}");
}
}
/// The writing direction of each of the frames `ids` (true: vertical).
fn vertical<const N: usize>(s: &Session, ids: [u64; N]) -> [bool; N] {
ids.map(|id| {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
t.vertical
})
}
/// Whether each frame of the thread `ids` holds the part of the story that its layout, in its
/// writing direction, has room for: the parts that [`vectorcraft_text::thread::distribute`]
/// gives the frames as they are now.
fn flows_as_laid_out(s: &Session, ids: &[u64]) -> bool {
let d = &s.doc().unwrap().doc;
let frames: Vec<&TextObject> = ids.iter().map(|id| text_of(d, NodeId(*id)).unwrap()).collect();
let story = vectorcraft_text::thread::story(&frames);
let paras = vectorcraft_text::thread::story_paras(&frames, &[]);
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &frames, &story, &paras);
let texts = |runs: &[TextRun]| runs.iter().map(|r| r.text.as_str()).collect::<String>();
parts.iter().zip(&frames).all(|(part, f)| texts(&part.runs) == f.plain_text())
}
/// Type Orientation is a setting of the whole thread: Type › Type Orientation in any frame sets
/// it in every frame of the thread, in one undo step, and the story flows through the frames in
/// that direction. Typing keeps it, and frames taken out of the thread keep it.
#[test]
fn type_orientation_is_set_for_every_frame_of_a_thread() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("type.orientation.vertical", &json!({})).unwrap();
assert_eq!(r, json!({"vertical": true, "ids": [a, b]}));
assert_eq!(vertical(&s, [a, b]), [true, true], "the second frame is vertical too");
assert!(flows_as_laid_out(&s, &[a, b]));
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
// Set back from the second frame, both are horizontal, and one undo makes both vertical.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
assert_eq!(s.execute("type.orientation.horizontal", &json!({})).unwrap()["ids"], json!([b, a]));
assert_eq!(vertical(&s, [a, b]), [false, false]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(vertical(&s, [a, b]), [true, true]);
// Typing in the first frame re-flows the story and keeps the direction.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap();
assert_eq!(vertical(&s, [a, b]), [true, true]);
assert!(flows_as_laid_out(&s, &[a, b]));
// Any edit that sets one frame sets the thread.
s.edit("Set", |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") };
t.vertical = false;
refresh_bounds(t);
Ok(())
})
.unwrap();
assert_eq!(vertical(&s, [a, b]), [false, false]);
assert!(flows_as_laid_out(&s, &[a, b]));
s.execute("edit.undo", &json!({})).unwrap();
// Released or unthreaded, each frame keeps it.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert_eq!(vertical(&s, [a, b]), [true, true]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(vertical(&s, [a, b]), [true, true]);
}
/// Threading type gives the joined thread the writing direction of its first frame, and the
/// story flows through every frame in that direction.
#[test]
fn a_thread_takes_its_first_frames_type_orientation() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let area = json!({"width": 150, "height": 60});
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": area, "vertical": true})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 60, "height": 200})).unwrap());
let c = new_id(s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!(vertical(&s, [a, b, c]), [true; 3]);
assert!(!text(&s, b).is_empty() && !text(&s, c).is_empty());
assert!(flows_as_laid_out(&s, &[a, b, c]), "the middle frame holds what fits it as vertical type");
// From `c`'s out port into horizontal area type `d`: `d` joins the thread and becomes vertical.
let d = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap());
s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap();
assert_eq!(threads(&s), [[a, b, c, d]]);
assert_eq!(vertical(&s, [a, b, c, d]), [true; 4]);
assert!(flows_as_laid_out(&s, &[a, b, c, d]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(vertical(&s, [d]), [false], "undo gives `d` its own direction back");
}
/// A file saved with the frames of one thread in different writing directions opens with the
/// first frame's direction in every frame, and the story flows through them in it: a native
/// file, and an EPS file restored from the document it carries.
#[test]
fn a_thread_saved_with_differing_orientations_opens_with_the_first_frames() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] {
for (id, on) in [(a, first), (b, !first)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.vertical = on;
refresh_bounds(t);
}
let bytes = if name.ends_with(".eps") {
let r = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
} else {
vectorcraft_format::save(&s.doc().unwrap().doc, false)
};
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
assert_eq!(threads(&s), [[a, b]], "{name}");
assert_eq!(vertical(&s, [a, b]), [first, first], "{name}, first frame vertical: {first}");
assert!(flows_as_laid_out(&s, &[a, b]), "{name}, first frame vertical: {first}");
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4), "{name}");
}
}
/// A thread restored from an EPS file whose frames share one writing direction keeps its text
/// in its frames as saved.
#[test]
fn a_restored_thread_in_one_direction_keeps_its_text_as_saved() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
let story = TEXT.repeat(4);
for (id, part) in [(a, &story[..10]), (b, &story[10..])] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.runs = vec![TextRun::new(part.to_string(), t.runs[0].style.clone())];
refresh_bounds(t);
}
let r = s.execute("document.exportEps", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], true, "{r}");
assert_eq!((text(&s, a), text(&s, b)), (story[..10].to_string(), story[10..].to_string()));
}
/// A threaded frame restored from a file with Auto Size on keeps its size when it turns from
/// vertical to horizontal: as the file opens, when the first frame is horizontal, and with Type
/// Orientation, when the whole thread is vertical.
#[test]
fn a_restored_threaded_frame_with_auto_size_keeps_its_size_when_it_turns_horizontal() {
for first_vertical in [false, true] {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
for (id, on) in [(a, first_vertical), (b, true)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.vertical = on;
if id == b {
t.area.fit = vectorcraft_doc::AreaFit::AutoHeight;
}
refresh_bounds(t);
}
let saved = frame_box(&s, b);
let r = s.execute("document.exportEps", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], true, "{r}");
if first_vertical {
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("type.orientation.horizontal", &json!({})).unwrap();
}
assert_eq!(vertical(&s, [a, b]), [false, false], "first frame vertical: {first_vertical}");
assert_eq!(frame_box(&s, b), saved, "first frame vertical: {first_vertical}");
assert!(flows_as_laid_out(&s, &[a, b]), "first frame vertical: {first_vertical}");
}
}
/// One count runs through a thread: the second frame's numbered paragraphs go on from the
/// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a
/// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks
/// and the frame a Release Selection leaves alone count from 1.
#[test]
fn numbered_list_paragraphs_count_on_through_a_thread() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
let numbers = |s: &Session, id: u64| {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
};
let all = |from: usize| (from..=10).map(|n| format!("{n}.")).collect::<Vec<_>>();
let first = numbers(&s, a);
assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}");
assert_eq!([first.clone(), numbers(&s, b)].concat(), all(1));
// Typing a paragraph into the first frame numbers it and moves the rest on.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "Item 0\n"})).unwrap();
assert_eq!([numbers(&s, a), numbers(&s, b)].concat(), [vec!["1.".to_string()], all(2), vec!["11.".to_string()]].concat());
s.execute("edit.undo", &json!({})).unwrap();
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
let after: Vec<String> = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).markers.iter().map(|m| m.text.clone()).collect();
assert_eq!(after, all(first.len() + 1), "reopened");
// A pasted copy of the second frame is in no thread.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.objects.first().copied().unwrap();
assert_ne!(copy, NodeId(b));
assert_eq!(numbers(&s, copy.0).first().map(String::as_str), Some("1."));
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let duplicate = s.execute("edit.duplicate", &json!({"dx": 10})).unwrap()["ids"][0].as_u64().unwrap();
assert_eq!(numbers(&s, duplicate).first().map(String::as_str), Some("1."), "a duplicate");
let layer = s.doc().unwrap().doc.layers[0].id.0;
let moved = s.execute("layer.move", &json!({"ids": [b], "target": layer, "copy": true})).unwrap()["ids"][0].as_u64().unwrap();
assert_eq!(numbers(&s, moved).first().map(String::as_str), Some("1."), "a copy dragged in the Layers panel");
assert_eq!(numbers(&s, b), all(first.len() + 1), "the frame itself");
// Convert To Point Type takes the second frame out of the thread with its text.
s.execute("type.convertToPointType", &json!({"ids": [b]})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."), "the frame converted to point type");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(numbers(&s, b), all(first.len() + 1), "undo");
// Remove Threading: each frame is a story of its own.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(numbers(&s, b), all(first.len() + 1), "undo");
// Release Selection of the first frame: the second one holds the whole story, from 1.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(numbers(&s, b), all(1), "the frame left holds the story");
}
/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art
/// looks as it did; Break Link puts the frames in no thread, and each counts from 1.
#[test]
fn a_symbol_made_of_a_numbered_thread_keeps_its_numbers() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
let count = |t: &TextObject| t.list_count;
let carried = text_of(&s.doc().unwrap().doc, NodeId(b)).map(count).unwrap();
assert!(carried > 0);
let instance = new_id(s.execute("symbol.new", &json!({"name": "Numbered"})).unwrap());
let counts = |n: &Node| {
let mut counts = vec![];
n.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
counts.push(t.list_count);
}
});
counts.sort_unstable();
counts
};
let doc = &s.doc().unwrap().doc;
assert_eq!(counts(&doc.symbols.iter().find(|x| x.name == "Numbered").unwrap().art), [0, carried]);
// Break Link puts the frames on the artboard in no thread: each counts from 1.
s.execute("select.set", &json!({"ids": [instance]})).unwrap();
s.execute("symbol.breakLink", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::<Vec<_>>(), [0, 0]);
}
/// The text of each frame in symbol `name`'s art, sorted.
fn symbol_texts(s: &Session, name: &str) -> Vec<String> {
let doc = &s.doc().unwrap().doc;
let Some(sym) = doc.symbols.iter().find(|x| x.name == name) else { panic!("no symbol {name}") };
let mut texts = vec![];
sym.art.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
texts.push(t.plain_text());
}
});
texts.sort();
texts
}
/// New Symbol and Redefine Symbol on frames of threaded text replace them with an instance
/// whose art holds the text they show, and the frames leave their thread: the other frames keep
/// the text they hold, so each character is drawn once. Undo threads the frames again. When the
/// last frame goes into the symbol, the frame before it takes the text it has no room for, and
/// the symbol's art holds only the text the frame shows.
#[test]
fn frames_made_into_a_symbol_leave_their_thread_with_their_text() {
let symbol = |s: &mut Session, command: &str, ids: &[u64]| {
let name = if command == "symbol.update" {
let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
s.execute("select.set", &json!({"ids": [mark]})).unwrap();
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
"Mark"
} else {
"Story"
};
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute(command, &json!({"name": name})).unwrap();
name
};
for command in ["symbol.new", "symbol.update"] {
for out in [&[0][..], &[1], &[2], &[0, 2]] {
let case = format!("{command} {out:?}");
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
let shown = frames.map(|id| text(&s, id));
assert_eq!(shown.concat(), TEXT.repeat(4));
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
let name = symbol(&mut s, command, &ids);
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
for id in &ids {
assert!(!is_frame(&s, *id), "{case}: frame {id} is in the symbol");
}
let mut held: Vec<String> = out.iter().map(|k| shown[*k].clone()).collect();
held.sort();
assert_eq!(symbol_texts(&s, name), held, "{case}: the symbol's art holds the text the frames show");
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text");
}
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [frames], "{case}: undo");
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
}
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
let shown = [a, b, c].map(|id| text(&s, id));
let laid = {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
};
assert!(laid.overflow, "the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let hidden = shown[2][end..].trim_start_matches(' ');
let name = symbol(&mut s, command, &[c]);
assert_eq!(text(&s, a), shown[0], "{command}");
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "{command}: the frame before it keeps its text and takes the rest");
assert_eq!(threads(&s), [[a, b]], "{command}");
let held = symbol_texts(&s, name);
let held: Vec<&str> = held.iter().map(|t| t.trim_end()).collect();
assert_eq!(held, [shown[2][..end].trim_end()], "{command}: the symbol's art holds what the frame shows");
// With every frame of the thread in the symbol, the last frame keeps all its text.
let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0);
let mut shown = frames.map(|id| text(&s, id)).to_vec();
shown.sort();
let name = symbol(&mut s, command, &frames);
assert_eq!(symbol_texts(&s, name), shown, "{command}: the whole thread");
assert!(threads(&s).is_empty(), "{command}: the whole thread");
}
}
/// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the
/// mask art too.
#[test]
fn a_frame_pasted_into_an_edited_opacity_mask_numbers_from_1() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
assert!(text_of(&s.doc().unwrap().doc, NodeId(b)).is_some_and(|t| t.list_count > 0));
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let obj = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 280})).unwrap());
let m = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [obj, m]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
let mut counts = vec![];
doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
counts.push(t.list_count);
}
});
assert_eq!(counts, [0], "the pasted frame in the mask art");
}
/// A duplicate of a frame that carried a count of 99 or more numbers from 1 and has the layout
/// bounds of what it draws: "01." leaves the text at the left indent, where a three-digit
/// number pushed it on to the next tab stop and the paragraph took more lines.
#[test]
fn a_duplicated_frame_numbers_from_1_with_its_own_bounds() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let story = format!("{}Itemized lists paragraph text", "x\n".repeat(110));
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 1500}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 300})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "number": "twoDigits"})).unwrap();
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let copy = s.execute("edit.duplicate", &json!({"dx": 200})).unwrap()["ids"][0].as_u64().unwrap();
let laid = |s: &Session, id: u64| {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("area type") };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
(lay.markers[0].text.clone(), lay.lines.len(), lay.bounds, t.cached_bounds.unwrap())
};
let (number, lines, _, _) = laid(&s, b);
let (copy_number, copy_lines, bounds, cached) = laid(&s, copy);
assert!(number.len() == 4 && number.starts_with('1'), "a three-digit number: {number}");
assert_eq!(copy_number, "01.");
assert!(copy_lines < lines, "{copy_lines} {lines}");
assert!((cached.y1 - bounds.y1).abs() < 1e-6, "the copy's bounds: {cached:?} {bounds:?}");
}
/// Release Selection that leaves one frame of a thread gives that frame the whole story and
/// the layout bounds of its new text.
#[test]
fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(text(&s, b), TEXT.repeat(4));
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(b)) else { panic!("area type") };
let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds;
let cached = t.cached_bounds.unwrap();
assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}");
}