|
|
|
@@ -153,17 +153,20 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
|
|
|
|
next.runs = part.runs;
|
|
|
|
|
next.set_paragraph_styles(part.paras);
|
|
|
|
|
let optical = optical.unwrap_or(t.optical_margin);
|
|
|
|
|
let continues = part.continues;
|
|
|
|
|
if next.runs != t.runs
|
|
|
|
|
|| next.para != t.para
|
|
|
|
|
|| next.paras != t.paras
|
|
|
|
|
|| optical != t.optical_margin
|
|
|
|
|
|| part.list_count != t.list_count
|
|
|
|
|
|| continues != t.first_para_continues
|
|
|
|
|
{
|
|
|
|
|
t.runs = next.runs;
|
|
|
|
|
t.para = next.para;
|
|
|
|
|
t.paras = next.paras;
|
|
|
|
|
t.optical_margin = optical;
|
|
|
|
|
t.list_count = part.list_count;
|
|
|
|
|
t.first_para_continues = continues;
|
|
|
|
|
refresh_bounds(t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -230,6 +233,25 @@ pub(crate) fn share_optical_margin(doc: &mut Document) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mark the frames of each thread whose first paragraph continues from the frame before
|
|
|
|
|
/// ([`TextObject::first_para_continues`]), without re-flowing the story: for a document restored
|
|
|
|
|
/// from a file as it was saved.
|
|
|
|
|
pub(crate) fn mark_continued(doc: &mut Document) {
|
|
|
|
|
for thread in doc.text_threads.clone() {
|
|
|
|
|
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
|
|
|
|
|
let frames: Vec<&TextObject> = live.iter().filter_map(|id| text_of(doc, *id)).collect();
|
|
|
|
|
let marks = vectorcraft_text::thread::continued(&frames);
|
|
|
|
|
for (id, on) in live.iter().zip(marks) {
|
|
|
|
|
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
|
|
|
|
&& t.first_para_continues != on
|
|
|
|
|
{
|
|
|
|
|
t.first_para_continues = on;
|
|
|
|
|
refresh_bounds(t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
|
|
|
|
|
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
|
|
|
|
let mut out = ids.to_vec();
|
|
|
|
@@ -255,7 +277,10 @@ pub(crate) fn frames(d: &Document) -> Vec<(NodeId, TextObject)> {
|
|
|
|
|
/// that type takes the frame's place in its thread. Otherwise (Create Outlines and Expand Appearance
|
|
|
|
|
/// made outlines of it) the frame leaves its thread with the text it shows, and the other frames keep
|
|
|
|
|
/// the text they hold. When a thread's last frame leaves, the text it has no room for goes after the
|
|
|
|
|
/// text of the last frame that stays. A thread left with one frame is no longer a thread.
|
|
|
|
|
/// text of the last frame that stays. A thread left with one frame is no longer a thread. Each frame
|
|
|
|
|
/// that stays continues a paragraph ([`TextObject::first_para_continues`]) only when it is still
|
|
|
|
|
/// threaded and the frames before it end inside one. A threaded frame whose mark changes lays out
|
|
|
|
|
/// its first line again, so its thread re-flows after the edit.
|
|
|
|
|
pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
|
|
|
|
|
let gone: Vec<(NodeId, &TextObject, Option<NodeId>)> =
|
|
|
|
|
was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, t, kept_type(d, *id))).collect();
|
|
|
|
@@ -263,6 +288,7 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let find = |id: &NodeId| gone.iter().find(|(g, _, _)| g == id);
|
|
|
|
|
let mut stayed = vec![];
|
|
|
|
|
for i in 0..d.text_threads.len() {
|
|
|
|
|
let thread = d.text_threads[i].clone();
|
|
|
|
|
if !thread.iter().any(|id| find(id).is_some()) {
|
|
|
|
@@ -278,9 +304,29 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
|
|
|
|
|
if let (Some((_, last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) {
|
|
|
|
|
take_overflow(d, *stay, last);
|
|
|
|
|
}
|
|
|
|
|
stayed.extend(keep.iter().copied());
|
|
|
|
|
d.text_threads[i] = keep;
|
|
|
|
|
}
|
|
|
|
|
d.text_threads.retain(|t| t.len() >= 2);
|
|
|
|
|
let marks: Vec<(NodeId, bool)> = stayed
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|id| {
|
|
|
|
|
let on = d.text_threads.iter().find(|t| t.contains(id)).is_some_and(|t| {
|
|
|
|
|
let live: Vec<(NodeId, &TextObject)> = t.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect();
|
|
|
|
|
let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect();
|
|
|
|
|
live.iter().zip(vectorcraft_text::thread::continued(&frames)).any(|((i, _), on)| on && i == id)
|
|
|
|
|
});
|
|
|
|
|
(*id, on)
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
for (id, on) in marks {
|
|
|
|
|
if text_of(d, id).is_some_and(|t| t.first_para_continues != on)
|
|
|
|
|
&& let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
|
|
|
|
{
|
|
|
|
|
t.first_para_continues = on;
|
|
|
|
|
refresh_bounds(t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The only area type or type on a path in the art at `id`.
|
|
|
|
@@ -301,8 +347,16 @@ fn kept_type(d: &Document, id: NodeId) -> Option<NodeId> {
|
|
|
|
|
fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) {
|
|
|
|
|
let db = vectorcraft_text::FontDb::global();
|
|
|
|
|
// `from`'s text split as if it flowed on into a second frame: the second part is what `from`
|
|
|
|
|
// has no room for.
|
|
|
|
|
let parts = vectorcraft_text::thread::distribute(db, &[from, from], &from.runs, &from.paragraph_styles());
|
|
|
|
|
// has no room for. Its share is measured as it is laid out, continuing a paragraph or not and
|
|
|
|
|
// with its numbered list paragraphs counted on from the frames before it.
|
|
|
|
|
let parts = vectorcraft_text::thread::distribute_continuing(
|
|
|
|
|
db,
|
|
|
|
|
&[from, from],
|
|
|
|
|
&from.runs,
|
|
|
|
|
&from.paragraph_styles(),
|
|
|
|
|
from.first_para_continues,
|
|
|
|
|
from.list_count,
|
|
|
|
|
);
|
|
|
|
|
let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return };
|
|
|
|
|
let mut tail = from.clone();
|
|
|
|
|
tail.runs = rest.runs.clone();
|
|
|
|
@@ -495,6 +549,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
|
|
|
|
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
|
|
|
|
t.runs = vec![TextRun { text: String::new(), style: style.clone(), inline: None }];
|
|
|
|
|
t.set_all_paras(first_para.clone());
|
|
|
|
|
t.first_para_continues = false;
|
|
|
|
|
refresh_bounds(t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -514,6 +569,8 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
|
|
|
|
vectorcraft_text::edit::normalize(&mut story);
|
|
|
|
|
t.runs = story;
|
|
|
|
|
t.set_paragraph_styles(paras);
|
|
|
|
|
// It starts the story, also when it is left alone and so out of a thread.
|
|
|
|
|
t.first_para_continues = false;
|
|
|
|
|
refresh_bounds(t);
|
|
|
|
|
}
|
|
|
|
|
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
|
|
|
|
@@ -528,7 +585,18 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
|
|
|
|
fn remove(s: &mut Session, _: &Value) -> Result<Value> {
|
|
|
|
|
let ids = selected_roots(s)?;
|
|
|
|
|
s.edit("Remove Threading", |d, _| {
|
|
|
|
|
d.text_threads.retain(|t| !t.iter().any(|i| ids.contains(i)));
|
|
|
|
|
let (gone, kept): (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) =
|
|
|
|
|
std::mem::take(&mut d.text_threads).into_iter().partition(|t| t.iter().any(|i| ids.contains(i)));
|
|
|
|
|
d.text_threads = kept;
|
|
|
|
|
// Each frame's first paragraph now starts in it.
|
|
|
|
|
for id in gone.iter().flatten() {
|
|
|
|
|
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind)
|
|
|
|
|
&& t.first_para_continues
|
|
|
|
|
{
|
|
|
|
|
t.first_para_continues = false;
|
|
|
|
|
refresh_bounds(t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
})?;
|
|
|
|
|
ok()
|
|
|
|
@@ -1059,6 +1127,230 @@ mod tests {
|
|
|
|
|
assert_eq!([a, b, c].map(|id| text(&s, id)), shown);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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) {
|
|
|
|
|