diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2a07cd55a..a222e8e91 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -2003,6 +2003,15 @@ impl Document { { dimension.source = None; } + // A copy of a threaded frame is in no thread, so its first paragraph starts in it and its + // layout caches are dropped. A caller that keeps the copy as an object fills them again. + if let NodeKind::Text(t) = &mut n.kind + && t.first_para_continues + { + t.first_para_continues = false; + t.cached_bounds = None; + t.cached_baselines.clear(); + } let mut renamed = std::collections::HashMap::new(); if let Some(ch) = n.children_mut() { let old: Vec> = std::mem::take(ch); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 60b5031e9..6ce257e78 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1627,6 +1627,12 @@ pub struct TextObject { /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub paras: Vec, + /// Threaded text: the first paragraph continues from the frame before this one in its thread, + /// so its first line here has no first-line indent and no list marker. The engine sets it for + /// every frame of a thread when the story flows through them and when a file opens, and clears + /// it on a frame that leaves its thread and on a copy. Not saved. + #[serde(skip)] + pub first_para_continues: bool, /// Area Type Options (area type only). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub area: AreaOptions, @@ -1759,6 +1765,7 @@ impl TextObject { ruby: Vec::new(), para: ParaStyle::default(), paras: Vec::new(), + first_para_continues: false, area: AreaOptions::default(), path_effect: PathEffect::default(), path_align: PathAlign::default(), diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index b8a844f31..dd841c661 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -206,6 +206,8 @@ pub(crate) fn draw_inside_group(d: &mut vectorcraft_doc::Document, target: NodeI let gid = d.alloc_id(); d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![std::sync::Arc::new(mask)], clip: true }))?; d.move_node(target, Some(gid), usize::MAX)?; + // Copied type whose layout caches `reid` dropped has them again. + super::typecmd::refresh_bounds_of(d, &[gid]); Ok(gid) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fdfe5c075..aadd456ef 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -896,7 +896,7 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio /// document, each original and its copy before the copy goes in, and must leave the document's /// tree as it is. The copies take new ids in the order of `roots`. Roots listed twice, or inside /// another root, are copied one at a time: each copy is made from the document as the copies -/// before it left it. +/// before it left it. Copied type whose layout caches [`Document::reid`] dropped has them again. pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl FnMut(&mut Document, &Node, &mut Node)) -> Result> { let paths = d.index_paths(roots.iter().copied()); let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect(); @@ -923,6 +923,7 @@ pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl Fn } out }; + super::typecmd::refresh_bounds_of(d, &out); Ok(out) } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index d7fe553be..c6e759988 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -323,6 +323,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { crate::cmd::threads::share_optical_margin(&mut doc); + // A frame whose first paragraph continues from the frame before draws it without the + // paragraph's first-line indent and list marker. + crate::cmd::threads::mark_continued(&mut doc); } if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index c90ac89da..05ec602e6 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -768,6 +768,10 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { let ch = d.children_mut(parent)?; let index = index.min(ch.len()); ch.splice(index..index, nodes); + // Copied type whose layout caches `reid` dropped has them again. + if copy { + super::typecmd::refresh_bounds_of(d, &out); + } sel.prune(d); sel.deselect_uneditable(d); Ok(out) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index c302a7409..5a0e2ec80 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -388,6 +388,10 @@ fn release(s: &mut Session, p: &Value) -> Result { let ch = d.children_mut(Some(parent))?; let at = index.min(ch.len()); ch.splice(at..at, layers); + // Copied type whose layout caches `reid` dropped has them again. + if build { + super::typecmd::refresh_bounds_of(d, &new); + } sel.prune(d); Ok(new) })?; diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 54d21415c..e9283f075 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -140,6 +140,8 @@ pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result let copy = d.reid(part); ids.push(d.insert(Some(layer), usize::MAX, copy)?); } + // Copied type whose layout caches `reid` dropped has them again. + super::typecmd::refresh_bounds_of(d, &ids); d.mask_edit = Some(MaskEdit { object: id, layer }); sel.set(ids); Ok(layer) diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index de1bf95ab..7a97ab871 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -237,6 +237,8 @@ fn release(s: &mut Session, p: &Value) -> Result { released.put(d, *id, parts)?; } released.finish(d)?; + // Copied type whose layout caches `reid` dropped has them again. + super::typecmd::refresh_bounds_of(d, &out); sel.set(out); Ok(()) })?; diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index c7267d4de..745086fa5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -892,6 +892,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { ruby: Vec::new(), para: super::create::new_type_para(), paras: Vec::new(), + first_para_continues: false, area: Default::default(), path_effect: Default::default(), path_align: Default::default(), @@ -1141,6 +1142,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { ruby: t.ruby.clone(), para: Default::default(), paras: Vec::new(), + first_para_continues: false, area: Default::default(), path_effect: Default::default(), path_align: Default::default(), diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 3131887b4..3df488404 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -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 = 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 { 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)> = 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 { 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 { 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 { 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 { fn remove(s: &mut Session, _: &Value) -> Result { 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>) = + 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 = out.iter().map(|k| frames[*k]).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + let stay: Vec = (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::(), 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) { diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index a61707b00..74d94ed80 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -329,6 +329,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let TextKind::Area { frame } = &t.kind else { continue }; let fb = frame.bounds().unwrap_or_default(); + // Point type is in no thread: its first paragraph starts in it. + t.first_para_continues = false; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let plain = t.plain_text(); let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index b04a45d00..4cf205012 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1953,8 +1953,17 @@ const MAX_COMPOSE_GLYPHS: usize = 200_000; /// Every-line composition of paragraph glyphs `sg` if applicable (area text, justified or ragged, /// with uniform line metrics or in rectangular cells without text wrap); `None` falls back to the -/// greedy single-line composer. -fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pen: &Pen<'_>, rtl: bool) -> Option> { +/// greedy single-line composer. With `continued`, the paragraph continues from the frame before, +/// and its first composed line is not its first line. +fn compose_para( + cx: &Ctx<'_>, + sg: &[SGlyph], + flex: &[Flex], + para: &ParaStyle, + pen: &Pen<'_>, + rtl: bool, + continued: bool, +) -> Option> { let ragged = matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); let composer = cx.opts.composer.unwrap_or(para.composer); // A paragraph whose commas or full stops may hang is composed line by line (as the Japanese @@ -1986,7 +1995,7 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pe // indents of a right-to-left list ([`line_indents`]) keep. while widths.len() < sg.len() { let first = widths.is_empty(); - let ind_l = para.left_indent + if first { para.first_line_indent } else { 0.0 }; + let ind_l = para.left_indent + if first && !continued { para.first_line_indent } else { 0.0 }; // Only the first line's first word is known here. Later lines are placed as the composed // lines need: each fits its width, so its first word does too. let word = if first { word_width(sg, 0) } else { 0.0 }; @@ -2081,9 +2090,12 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how // far a justified line may move each one from it. let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + // Threaded text: a first paragraph that continues from the frame before has its first + // line, first-line indent and list marker there. + let continued = pi == 0 && t.first_para_continues; // Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph. // Justification and Optical Margin Alignment leave them as they are. - let marker = cx.prepend_marker(pr, para, marker_text, rtl, &mut sg); + let marker = if continued { 0 } else { cx.prepend_marker(pr, para, marker_text, rtl, &mut sg) }; if marker > 0 { flex.splice(0..0, vec![Flex::default(); marker]); if !compressed.is_empty() { @@ -2112,7 +2124,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio pen.pending += para.space_before; } let n = sg.len(); - let composed = compose_para(cx, &sg, &flex, para, &pen, rtl); + let composed = compose_para(cx, &sg, &flex, para, &pen, rtl, continued); // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the // Hyphen Limit counts the lines in a row that end with a hyphen. let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; @@ -2123,7 +2135,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let mirror = mirrors_indents(para, rtl, cx.vertical); loop { let est = if i < n { Metrics::of(&sg[i]) } else { pm }; - let first_line = li_para == 0; + let first_line = li_para == 0 && !continued; let (ind_l, ind_r) = line_indents(para, first_line, mirror); // Measured before Mojikumi narrows an opening bracket, as the composer measures it. let word = word_width(&sg, i); @@ -2543,9 +2555,12 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical); let mut glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); - // Bullets and numbers: a list paragraph's text starts after its marker here too. - let marker_text = markers.get(pi).and_then(Option::as_deref); - cx.prepend_marker(pr, t.para_at(pi), marker_text, is_rtl(bidi.as_ref()) && !cx.vertical, &mut glyphs); + // Bullets and numbers: a list paragraph's text starts after its marker here too, unless + // the paragraph continues from the frame before in its thread. + if pi > 0 || !t.first_para_continues { + let marker_text = markers.get(pi).and_then(Option::as_deref); + cx.prepend_marker(pr, t.para_at(pi), marker_text, is_rtl(bidi.as_ref()) && !cx.vertical, &mut glyphs); + } shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs)); } let rtl = rtl.unwrap_or(false); diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index 6d5fb8617..0ed8ecd93 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -78,6 +78,9 @@ pub struct Slice { pub runs: Vec, pub paras: Vec, pub list_count: u32, + /// The first paragraph continues from the frame before + /// ([`TextObject::first_para_continues`]). + pub continues: bool, } /// The count of numbered list paragraphs after the paragraphs of `runs` (styles `paras`, one per @@ -94,16 +97,53 @@ fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut count: u32) count } +/// Whether the frame after one whose share ends with byte `before` starts inside a paragraph: it +/// does unless that byte is a paragraph break. After an empty share (`None`) it takes `was`, the +/// mark of the frame with the empty share. +fn continues_after(before: Option, was: bool) -> bool { + before.map_or(was, |b| b != b'\n') +} + +/// For each of `frames` in thread order, whether its first paragraph continues from the frames +/// before it ([`TextObject::first_para_continues`]): the text of those frames is not empty and +/// does not end with a paragraph break. +pub fn continued(frames: &[&TextObject]) -> Vec { + let mut on = false; + frames + .iter() + .map(|f| { + let this = on; + on = continues_after(f.runs.iter().rev().find_map(|r| r.text.bytes().last()), on); + this + }) + .collect() +} + /// The story `runs` (with paragraph styles `paras`, one per story paragraph; empty = each /// frame's own) split across `frames` in order (the last frame takes any overflow). Empty slices /// keep one empty run carrying the style at the split, so typing there has a style. A paragraph -/// split between frames has its style in both. Each frame's numbered list paragraphs are counted -/// on from the frames before it. +/// split between frames has its style in both, and each slice that starts inside a paragraph is +/// marked as continuing it ([`Slice::continues`]). Each frame's numbered list paragraphs are counted +/// on from the frames before it. Each frame's share is measured with that mark and that count. pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec { + distribute_continuing(db, frames, runs, paras, false, 0) +} + +/// [`distribute`] for `runs` that follow text before them: the first frame's share is measured, +/// and marked, as continuing a paragraph when `continued` is set, and its numbered list paragraphs +/// are counted on from `list_count`. +pub fn distribute_continuing( + db: &FontDb, + frames: &[&TextObject], + runs: &[TextRun], + paras: &[ParaStyle], + continued: bool, + mut list_count: u32, +) -> Vec { let mut rest = runs.to_vec(); let mut out = Vec::with_capacity(frames.len()); let mut next_para = 0; - let mut list_count = 0; + let mut continues = continued; for (i, f) in frames.iter().enumerate() { let first_para = next_para; let len = runs_len(&rest); @@ -116,6 +156,7 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: probe.runs = rest.clone(); probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); probe.list_count = list_count; + probe.first_para_continues = continues; fit(db, &probe, &rest) }; let mut head = slice_runs(&rest, 0, n); @@ -127,7 +168,9 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: let head_paras = slice_paras(paras, first_para, &head); next_para += head_paras.len().saturating_sub(1); let next_count = count_list_paragraphs(&head, &head_paras, list_count); - out.push(Slice { runs: head, paras: head_paras, list_count }); + let starts_continued = continues; + continues = continues_after(head.iter().rev().find_map(|r| r.text.bytes().last()), continues); + out.push(Slice { runs: head, paras: head_paras, list_count, continues: starts_continued }); list_count = next_count; } out @@ -282,8 +325,8 @@ mod tests { } /// Numbered list paragraphs count on from one frame to the next: the second frame's first - /// whole paragraph takes the next number. A paragraph split between the frames is numbered - /// alike in both, and the paragraph after it takes the next number. + /// whole paragraph takes the next number. A paragraph split between the frames has its number + /// in the first frame only, and the paragraph after it takes the next number. #[test] fn numbered_list_paragraphs_count_on_through_the_frames() { use vectorcraft_doc::text::{NumberStyle, ParaList}; @@ -297,6 +340,7 @@ mod tests { t.runs = s.runs.clone(); t.set_paragraph_styles(s.paras.clone()); t.list_count = s.list_count; + t.first_para_continues = s.continues; layout(db, &t).markers.iter().map(|m| m.text.clone()).collect::>() }; // Whole paragraphs: the first frame ends with a paragraph break. @@ -313,10 +357,153 @@ mod tests { let text = format!("One\nTwo\n{}\nFour", "The quick brown fox jumps over the lazy dog. ".repeat(4)); let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; let parts = distribute(db, &[&a, &b], &story, &vec![numbered; 4]); - assert_eq!( - (numbers(&a, &parts[0]), numbers(&b, &parts[1])), - (vec!["1.".to_string(), "2.".into(), "3.".into()], vec!["3.".to_string(), "4.".into()]) - ); + assert_eq!(parts.iter().map(|s| s.continues).collect::>(), [false, true]); + assert_eq!((numbers(&a, &parts[0]), numbers(&b, &parts[1])), (vec!["1.".to_string(), "2.".into(), "3.".into()], vec!["4.".to_string()])); + } + + /// Frame `f` holding slice `p`, laid out. + fn laid(db: &FontDb, f: &TextObject, p: &Slice) -> crate::TextLayout { + let mut t = f.clone(); + t.runs = p.runs.clone(); + t.set_paragraph_styles(p.paras.clone()); + t.list_count = p.list_count; + t.first_para_continues = p.continues; + layout(db, &t) + } + + /// Where line `k` of `lay` starts: its first glyph that stands for a character. + fn line_start(lay: &crate::TextLayout, k: usize) -> f64 { + let l = &lay.lines[k]; + lay.glyphs[l.glyph_start..l.glyph_end].iter().find(|g| g.len > 0).map_or(f64::NAN, |g| g.origin.x) + } + + /// A paragraph's first-line indent and bullet belong to its first line: a paragraph that + /// continues from one frame into the next starts the next frame at its left indent, without its + /// bullet, and every line stays inside its frame. A paragraph that starts at the top of a frame + /// keeps both there. + #[test] + fn a_paragraph_continued_into_the_next_frame_has_no_first_line_indent_or_bullet_there() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let db = FontDb::global(); + let (a, b, mut c) = (frame(0.0), frame(200.0), frame(400.0)); + c.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(400.0, 0.0, 520.0, 300.0)) }; + let bulleted = ParaStyle { left_indent: 18.0, first_line_indent: -18.0, list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }; + let indented = ParaStyle { first_line_indent: 30.0, ..Default::default() }; + let long = "The quick brown fox jumps over the lazy dog. ".repeat(9) + "\nSecond paragraph."; + for para in [bulleted, indented] { + let (left, first) = (para.left_indent, para.left_indent + para.first_line_indent); + let list = usize::from(para.list.is_some()); + let story = vec![TextRun { text: long.clone(), style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b, &c], &story, &[para.clone(), para.clone()]); + assert_eq!( + parts.iter().map(|p| p.continues).collect::>(), + [false, true, true], + "{:?}", + parts.iter().map(|p| runs_len(&p.runs)).collect::>() + ); + let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); + assert_eq!(joined, long, "no character lost or duplicated"); + // [`continued`] reads the same marks from the frames' text. + assert_eq!(continued(&filled(&[&a, &b, &c], &parts).iter().collect::>()), [false, true, true]); + let lays: Vec<_> = [&a, &b, &c].iter().zip(&parts).map(|(f, p)| laid(db, f, p)).collect(); + // The first frame: the first line at the first-line indent, after the bullet. + assert_eq!(lays[0].markers.len(), list, "{left}"); + assert!((line_start(&lays[0], 0) - (if list > 0 { left } else { first })).abs() < 1e-6, "{}", line_start(&lays[0], 0)); + if list > 0 { + assert!(lays[0].glyphs[lays[0].markers[0].glyphs.start].origin.x.abs() < 1e-6, "the bullet starts the first frame"); + } + // The frames it continues into: every line at the left indent, no bullet. + for (k, (x0, lay)) in [200.0, 400.0].into_iter().zip(&lays[1..]).enumerate() { + let on_first = lay.markers.iter().filter(|m| lay.glyphs[m.glyphs.start].line == 0).count(); + assert_eq!(on_first, 0, "frame {} starts without the bullet", k + 1); + assert!((line_start(lay, 0) - x0 - left).abs() < 1e-6, "frame {} starts at {}", k + 1, line_start(lay, 0) - x0); + assert!(!lay.overflow, "frame {} holds its share", k + 1); + for l in &lay.lines { + assert!(l.x1 <= x0 + 120.0 + 1e-6, "frame {}: a line ends at {}", k + 1, l.x1 - x0); + } + } + // The second paragraph, in the last frame, starts with its own first line. + let last = &lays[2]; + let shown: String = parts[2].runs.iter().map(|r| r.text.as_str()).collect(); + let second = last.lines.iter().position(|l| shown.get(l.start..).is_some_and(|s| s.starts_with("Second"))).unwrap(); + assert!((line_start(last, second) - 400.0 - if list > 0 { left } else { first }).abs() < 1e-6, "{}", line_start(last, second)); + assert_eq!(last.markers.len(), list, "the second paragraph's bullet"); + + // One-line paragraphs: the second frame starts with a paragraph of its own. + let lines = "Short line.\n".repeat(12); + let story = vec![TextRun { text: lines.clone(), style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b], &story, std::slice::from_ref(¶)); + assert_eq!(parts.iter().map(|p| p.continues).collect::>(), [false, false]); + let lay = laid(db, &b, &parts[1]); + assert!((line_start(&lay, 0) - 200.0 - if list > 0 { left } else { first }).abs() < 1e-6, "{}", line_start(&lay, 0)); + if list > 0 { + assert_eq!(lay.glyphs[lay.markers[0].glyphs.start].line, 0, "the bullet starts the second frame"); + assert!((lay.glyphs[lay.markers[0].glyphs.start].origin.x - 200.0).abs() < 1e-6); + } + assert_eq!(continued(&filled(&[&a, &b], &parts).iter().collect::>()), [false, false]); + } + } + + /// A frame that a paragraph continues into holds what fits it laid out that way: its share does + /// not overflow it, and the share with the next word added does. + #[test] + fn a_continued_paragraph_fills_the_frame_it_continues_into() { + let db = FontDb::global(); + let (a, b, mut c) = (frame(0.0), frame(200.0), frame(400.0)); + c.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(400.0, 0.0, 520.0, 300.0)) }; + let hanging = ParaStyle { left_indent: 18.0, first_line_indent: -18.0, ..Default::default() }; + let indented = ParaStyle { first_line_indent: 30.0, ..Default::default() }; + let mut checked = 0; + for para in [hanging, indented] { + for k in 0..8 { + let text = "a ".repeat(k) + &"The quick brown fox jumps over the lazy dog. ".repeat(9); + let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b, &c], &story, std::slice::from_ref(¶)); + assert!(parts[1].continues, "{k}"); + assert!(!laid(db, &b, &parts[1]).overflow, "the second frame holds its share ({k})"); + let next: String = parts[2].runs.iter().map(|r| r.text.as_str()).collect(); + let word = next.split_inclusive(' ').next().unwrap_or_default(); + let mut more = parts[1].clone(); + more.runs.push(TextRun { text: word.to_string(), style: CharStyle::default(), inline: None }); + normalize(&mut more.runs); + assert!(laid(db, &b, &more).overflow, "the second frame has no room for {word:?} ({k})"); + checked += 1; + } + } + assert_eq!(checked, 16); + } + + /// `frames` holding the runs of `parts`, for [`continued`]. + fn filled(frames: &[&TextObject], parts: &[Slice]) -> Vec { + frames + .iter() + .zip(parts) + .map(|(f, p)| { + let mut t = (*f).clone(); + t.runs = p.runs.clone(); + t + }) + .collect() + } + + /// A paragraph that continues onto type on a path starts the path without its bullet. + #[test] + fn a_list_paragraph_continued_onto_a_path_has_no_bullet_there() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let db = FontDb::global(); + let para = ParaStyle { left_indent: 18.0, first_line_indent: -18.0, list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }; + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let (a, b) = (frame(0.0), path_frame(300.0)); + let parts = distribute(db, &[&a, &b], &story, std::slice::from_ref(¶)); + assert_eq!(parts.iter().map(|p| p.continues).collect::>(), [false, true]); + let (la, lb) = (laid(db, &a, &parts[0]), laid(db, &b, &parts[1])); + assert_eq!((la.markers.len(), lb.markers.len()), (1, 0)); + // The same paragraph starting on the path has its bullet there. + let mut alone = b.clone(); + alone.runs = story; + alone.set_paragraph_styles(vec![para]); + assert_eq!(layout(db, &alone).markers.len(), 1); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index b9f832033..8461ed0c3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4205,6 +4205,12 @@ that doesn't exist, a `from` that is point type, a `to` that is an open path, `f or already in a thread (`from` may be threaded already: the frame goes in beside it), coordinates that aren't finite numbers and a `before` that isn't true or false. +In every thread, a paragraph that continues from one frame into the next has its first line, first-line indent and +bullet or number in the frame where it starts. In the next frame its first line starts where its wrapped lines start, +and it has no bullet or number. In a frame that is no longer threaded (after `text.thread.releaseSelection` or +`text.thread.remove`), in a copy of a frame and in point type converted from one, the first paragraph has its first-line +indent and bullet or number again. + ```json {"name":"run_command","arguments":{"command":"text.thread.add","params":{"from":42,"at":[300,120]}}} ``` @@ -4398,8 +4404,8 @@ bullets and numbers. In a horizontal right-to-left list paragraph the indents ar indents are measured from the right edge, and the right indent from the left edge. Its marker starts the first line at the right, and the lines it wraps to start at the right where the first line's text starts after the marker. A right-to-left paragraph without a list keeps its left and first-line indents at the left, and vertical type keeps its -indents as they are. In threaded type, a list paragraph that continues into the next frame starts that frame with its -bullet or number again, as it starts it with its first-line indent. +indents as they are. In threaded type, a list paragraph that continues into the next frame starts that frame where its +wrapped lines start, without the bullet or number, and the paragraph after it takes the next number. The layout draws the marker (the bullet, or the number and its period) before the first line of each list paragraph that has text, in the font, size, scales and color of the paragraph's first character, without that character's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 16dd4b6c7..016e7a771 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -763,6 +763,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). + A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again. Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once. Type › Create Outlines, Object › Expand and Object › Expand Appearance on a frame of threaded text take it out of the thread with the text it shows, which the outlines draw, and the other frames keep the text they hold. When the last frame is outlined, the text it has no room for goes after the text of the last frame that stays. When Expand Appearance keeps the frame as type in a group with its shadow, outer glow or Crop Marks, the type stays in the thread. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.