mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-11 16:30:16 -06:00
Merge current main into the text editing and layout work, retaining the original contributor commits. Preserve generated and linked stories, incoming text references, local IDs and locked frames when threading. Keep pending formatting and text colours consistent, isolate deferred control clipboard input, and handle inherited IDML frame properties with bounded column allocations. Add focused state-preservation and round-trip regressions and correct the socket and swatch test fixtures. M2; refs #84.
278 lines
12 KiB
Rust
278 lines
12 KiB
Rust
use designcraft_doc::{ParaFormat, Story, StoryId};
|
|
use designcraft_engine::Session;
|
|
use serde_json::{Value, json};
|
|
use std::sync::Arc;
|
|
fn setup() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({})).unwrap();
|
|
s
|
|
}
|
|
fn frame(s: &mut Session, text: &str) -> (u64, u64) {
|
|
let r = s.execute("frame.create", &json!({"rect":[72,72,300,300],"content":"text","text":text,"caret":false})).unwrap();
|
|
(r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap())
|
|
}
|
|
fn snapshot(s: &Session) -> Value {
|
|
let d = s.doc().unwrap();
|
|
json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":d.history.undo.len(),"redo":d.history.redo.len(),"journal":s.journal})
|
|
}
|
|
#[test]
|
|
fn rejected_thread_is_atomic_with_new_frame_and_redo() {
|
|
let mut s = setup();
|
|
let (a, _) = frame(&mut s, "hello");
|
|
frame(&mut s, "undo");
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let before = snapshot(&s);
|
|
assert!(s.execute("text.thread", &json!({"from":999999,"rect":[0,0,100,100]})).is_err());
|
|
assert_eq!(snapshot(&s), before);
|
|
assert!(s.execute("text.thread", &json!({"from":a,"rect":[0,0,100,100],"spread":999})).is_err());
|
|
assert_eq!(snapshot(&s), before);
|
|
}
|
|
#[test]
|
|
fn locked_frame_cannot_be_unthreaded() {
|
|
let mut s = setup();
|
|
let (a, _) = frame(&mut s, "hello");
|
|
let (b, _) = frame(&mut s, "");
|
|
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
|
|
s.execute("object.lock", &json!({"ids":[a]})).unwrap();
|
|
let before = snapshot(&s);
|
|
let r = s.execute("text.unthread", &json!({"frame":a}));
|
|
assert!(r.is_err(), "unthread accepted locked frame: {r:?}");
|
|
assert_eq!(snapshot(&s), before);
|
|
}
|
|
#[test]
|
|
fn locked_frame_cannot_be_removed_from_thread() {
|
|
let mut s = setup();
|
|
let (a, _) = frame(&mut s, "hello");
|
|
let (b, _) = frame(&mut s, "");
|
|
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
|
|
s.execute("object.lock", &json!({"ids":[a]})).unwrap();
|
|
let before = snapshot(&s);
|
|
let r = s.execute("text.removeFromThread", &json!({"frame":a}));
|
|
assert!(r.is_err(), "remove accepted locked frame: {r:?}");
|
|
assert_eq!(snapshot(&s), before);
|
|
}
|
|
#[test]
|
|
fn rejected_selection_preserves_pending_typing() {
|
|
let mut s = setup();
|
|
let (_, sid) = frame(&mut s, "hello");
|
|
s.execute("text.select", &json!({"story":sid,"anchor":3})).unwrap();
|
|
s.execute("type.char", &json!({"attrs":{"size":30}})).unwrap();
|
|
let before = s.execute("type.selectionAttrs", &json!({})).unwrap();
|
|
assert!(s.execute("text.select", &json!({"story":999999,"anchor":0})).is_err());
|
|
let after = s.execute("type.selectionAttrs", &json!({})).unwrap();
|
|
assert_eq!(after, before, "rejected selection reset pending typing");
|
|
}
|
|
#[test]
|
|
fn append_max_id_preserves_both_tables() {
|
|
use designcraft_doc::table::Table;
|
|
let mut a = Story::with_text(StoryId(1), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default());
|
|
let mut b = Story::with_text(StoryId(2), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default());
|
|
let mut ta = Table::new(u64::MAX, 1, 1, 0, 0, 100.);
|
|
ta.cells[0].text = Story::with_text(StoryId(0), "ORIGINAL", ParaFormat::default());
|
|
a.tables.insert(u64::MAX, Arc::new(ta));
|
|
let mut tb = Table::new(1, 1, 1, 0, 0, 100.);
|
|
tb.cells[0].text = Story::with_text(StoryId(0), "INCOMING", ParaFormat::default());
|
|
b.tables.insert(1, Arc::new(tb));
|
|
a.paras[0].table = Some(u64::MAX);
|
|
b.paras[0].table = Some(1);
|
|
a.check().unwrap();
|
|
b.check().unwrap();
|
|
a.append_story(b);
|
|
let contents: Vec<_> = a.tables.values().map(|t| t.cells[0].text.text.clone()).collect();
|
|
assert!(contents.contains(&"ORIGINAL".into()) && contents.contains(&"INCOMING".into()), "table loss: {contents:?}");
|
|
}
|
|
#[test]
|
|
fn idml_dropcap_detail_preserves_inherited_other_bit() {
|
|
use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, build::NewDocument};
|
|
let mut d = Document::new(&NewDocument::default());
|
|
let parent = ParagraphStyle {
|
|
name: "Parent".into(),
|
|
based_on: None,
|
|
next_style: None,
|
|
para: ParaAttrs { drop_cap_align_left: Some(false), drop_cap_scale_descenders: Some(true), ..Default::default() },
|
|
chars: Default::default(),
|
|
shortcut: String::new(),
|
|
};
|
|
let child = ParagraphStyle {
|
|
name: "Child".into(),
|
|
based_on: Some("Parent".into()),
|
|
next_style: None,
|
|
para: ParaAttrs { drop_cap_scale_descenders: Some(false), ..Default::default() },
|
|
chars: Default::default(),
|
|
shortcut: String::new(),
|
|
};
|
|
d.styles_mut().paragraph.extend([parent, child]);
|
|
let before = d.styles.resolve_para_style("Child").0;
|
|
let bytes = designcraft_idml::export_idml(&d);
|
|
let back = designcraft_idml::import_idml(&bytes).unwrap();
|
|
let after = back.styles.resolve_para_style("Child").0;
|
|
assert_eq!(
|
|
(after.drop_cap_align_left, after.drop_cap_scale_descenders),
|
|
(before.drop_cap_align_left, before.drop_cap_scale_descenders),
|
|
"IDML export resets inherited align-left bit"
|
|
);
|
|
}
|
|
#[test]
|
|
fn threading_preserves_incoming_text_hyperlink() {
|
|
let mut s = setup();
|
|
let (a, sa) = frame(&mut s, "First");
|
|
let (b, sb) = frame(&mut s, "Second");
|
|
s.execute("text.select", &json!({"story":sb,"anchor":0,"focus":6})).unwrap();
|
|
s.execute("hyperlink.create", &json!({"url":"https://example.com"})).unwrap();
|
|
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
let back = designcraft_format::load(&designcraft_format::save(d).unwrap()).unwrap();
|
|
match &back.hyperlinks[0].source {
|
|
designcraft_doc::HyperlinkSource::Text { story, start, end } => {
|
|
assert_eq!(story.0, sa, "hyperlink points to deleted incoming story");
|
|
assert_eq!((*start, *end), (6, 12));
|
|
}
|
|
_ => panic!("wrong source"),
|
|
};
|
|
}
|
|
#[test]
|
|
fn thread_cannot_destroy_body_by_joining_generated_endnotes() {
|
|
let mut s = setup();
|
|
let (body, sid) = frame(&mut s, "KEEP THIS BODY");
|
|
s.execute("text.select", &json!({"story":sid,"anchor":14})).unwrap();
|
|
let e = s.execute("endnote.insert", &json!({"text":"a note"})).unwrap();
|
|
let ef = e["frame"].as_u64().unwrap();
|
|
let before = snapshot(&s);
|
|
let r = s.execute("text.thread", &json!({"from":ef,"to":body}));
|
|
assert!(
|
|
r.is_err(),
|
|
"thread into generated endnotes accepted: {r:?}; stories: {:?}",
|
|
s.doc().unwrap().doc.stories.values().map(|s| &s.text).collect::<Vec<_>>()
|
|
);
|
|
assert_eq!(snapshot(&s), before);
|
|
}
|
|
#[test]
|
|
fn generated_endnotes_accept_empty_extensions_but_reject_reverse_body_merge() {
|
|
let mut s = setup();
|
|
let (body, sid) = frame(&mut s, "Body");
|
|
s.execute("text.select", &json!({"story":sid,"anchor":4})).unwrap();
|
|
let r = s.execute("endnote.insert", &json!({"text":"note"})).unwrap();
|
|
let ef = r["frame"].as_u64().unwrap();
|
|
let esid = s.doc().unwrap().doc.endnote_story.unwrap();
|
|
let before = snapshot(&s);
|
|
assert!(s.execute("text.thread", &json!({"from":body,"to":ef})).is_err());
|
|
assert_eq!(snapshot(&s), before);
|
|
s.execute("text.thread", &json!({"from":ef,"rect":[72,72,300,300]})).unwrap();
|
|
assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid));
|
|
s.execute("text.thread", &json!({"to":ef,"rect":[72,72,300,300]})).unwrap();
|
|
assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid));
|
|
assert_eq!(s.doc().unwrap().doc.story(esid).unwrap().frames.len(), 3);
|
|
assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text.starts_with("Body"));
|
|
s.doc().unwrap().doc.check().unwrap();
|
|
}
|
|
#[test]
|
|
fn threading_moves_data_placeholders_and_primary_story_with_text() {
|
|
use designcraft_doc::{Placeholder, PlaceholderAnchor, PlaceholderRole};
|
|
let mut s = setup();
|
|
let (a, sa) = frame(&mut s, "First");
|
|
let (b, sb) = frame(&mut s, "<<name>>");
|
|
s.edit(|d, _| {
|
|
d.settings.primary_story = Some(StoryId(sb));
|
|
d.data_merge.placeholders.push(Placeholder {
|
|
id: 123,
|
|
source_id: 0,
|
|
field: "name".into(),
|
|
role: PlaceholderRole::Text,
|
|
anchor: PlaceholderAnchor::Text { story: StoryId(sb), start: 0, end: 8 },
|
|
});
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
let before = s.doc().unwrap().doc.clone();
|
|
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
assert_eq!(d.settings.primary_story, Some(StoryId(sa)));
|
|
assert_eq!(d.data_merge.placeholders.len(), 1);
|
|
assert_eq!(d.data_merge.placeholders[0].anchor, PlaceholderAnchor::Text { story: StoryId(sa), start: 6, end: 14 });
|
|
let after = d.clone();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(s.doc().unwrap().doc, before);
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
assert_eq!(s.doc().unwrap().doc, after);
|
|
}
|
|
#[test]
|
|
fn append_max_ids_preserves_addressable_footnotes_and_endnotes() {
|
|
let mut a = Story::with_text(StoryId(1), "A", ParaFormat::default());
|
|
let mut b = Story::with_text(StoryId(2), "B", ParaFormat::default());
|
|
a.insert_note(1, "original footnote", ParaFormat::default());
|
|
b.insert_note(1, "incoming footnote", ParaFormat::default());
|
|
Arc::make_mut(&mut a.notes[0]).id = u64::MAX;
|
|
a.insert_endnote(1, "original endnote", ParaFormat::default());
|
|
b.insert_endnote(1, "incoming endnote", ParaFormat::default());
|
|
Arc::make_mut(&mut a.endnotes[0]).id = u64::MAX;
|
|
a.check().unwrap();
|
|
b.check().unwrap();
|
|
a.append_story(b);
|
|
a.check().unwrap();
|
|
assert_ne!(a.notes[0].id, a.notes[1].id);
|
|
assert_ne!(a.endnotes[0].id, a.endnotes[1].id);
|
|
assert_eq!(a.notes[0].text.text, "original footnote");
|
|
assert_eq!(a.endnotes[1].text.text, "incoming endnote");
|
|
}
|
|
#[test]
|
|
fn locked_layer_and_neighbour_reject_unthread_atomically() {
|
|
for lock_layer in [false, true] {
|
|
let mut s = setup();
|
|
let (a, _) = frame(&mut s, "A");
|
|
let (b, _) = frame(&mut s, "B");
|
|
s.execute("text.thread", &json!({"from":a,"to":b})).unwrap();
|
|
if lock_layer {
|
|
let lid = s.doc().unwrap().active_layer.0;
|
|
s.execute("layer.set", &json!({"id":lid,"locked":true})).unwrap();
|
|
} else {
|
|
s.execute("object.lock", &json!({"ids":[b]})).unwrap();
|
|
}
|
|
let before = snapshot(&s);
|
|
assert!(s.execute("text.unthread", &json!({"frame":a})).is_err());
|
|
assert_eq!(snapshot(&s), before);
|
|
}
|
|
}
|
|
#[test]
|
|
fn idml_partial_dropcap_bits_resolve_both_style_and_paragraph_inheritance() {
|
|
use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, SpreadRef, build::NewDocument};
|
|
use designcraft_geom::Rect;
|
|
for parent_align in [false, true] {
|
|
for parent_scale in [false, true] {
|
|
for local_align in [false, true] {
|
|
let mut d = Document::new(&NewDocument::default());
|
|
d.styles_mut().paragraph.push(ParagraphStyle {
|
|
name: "Parent".into(),
|
|
based_on: None,
|
|
next_style: None,
|
|
para: ParaAttrs { drop_cap_align_left: Some(parent_align), drop_cap_scale_descenders: Some(parent_scale), ..Default::default() },
|
|
chars: Default::default(),
|
|
shortcut: String::new(),
|
|
});
|
|
let layer = d.default_layer();
|
|
let (_, sid) = d
|
|
.add_text_frame(
|
|
SpreadRef::Doc(0),
|
|
Rect::new(10., 10., 200., 200.),
|
|
layer,
|
|
"Body",
|
|
ParaFormat {
|
|
style: "Parent".into(),
|
|
para: ParaAttrs { drop_cap_align_left: Some(local_align), ..Default::default() },
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
let bytes = designcraft_idml::export_idml(&d);
|
|
let back = designcraft_idml::import_idml(&bytes).unwrap();
|
|
let st = back.stories.values().find(|s| s.text == "Body").unwrap();
|
|
let actual = back.styles.resolve_para(&st.paras[0]).0;
|
|
let expected = d.styles.resolve_para(&d.story(sid).unwrap().paras[0]).0;
|
|
assert_eq!(
|
|
(actual.drop_cap_align_left, actual.drop_cap_scale_descenders),
|
|
(expected.drop_cap_align_left, expected.drop_cap_scale_descenders)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|