Type: threaded frames do not repeat a continued paragraph's first-line indent or list marker

A paragraph split between two threaded frames took its first-line
indent again at the top of the next frame, and a list paragraph drew
its bullet or number there again. Each frame is laid out on its own,
and its first paragraph kept every paragraph attribute there.

A text object now marks a frame whose first paragraph continues from
the frame before (first_para_continues, not saved). The thread re-flow
sets the mark where each frame's share starts inside a paragraph and
measures each share with it, and a restored document reads the marks
from its saved text. Area type and type on a path set the first line of
such a paragraph as a wrapped line: no first-line indent and no bullet
or number. The paragraph after it takes the next number. Release
Selection, Remove Threading, Convert To Point Type and copies of a
frame clear the mark, so the first paragraph there has its indent and
bullet or number again. When Create Outlines, Expand or Expand
Appearance takes frames out of a thread, a frame that stays keeps the
mark only while it is threaded and the frames before it end inside a
paragraph. A frame whose mark changes lays out its first line again,
and when it is still threaded, its thread re-flows. When the outlined
frame is the last one, the text it has no room for is measured as the
frame lays it out: without the first-line indent when it continues a
paragraph, and with its numbers counted on from the frames before it.
The frame before it now takes exactly the text the last frame has no
room for. Duplicates, copies dragged in the Layers panel, the copies
that Release to Layers (Build), Release Opacity Mask and Edit Opacity
Mask make, and Draw Inside's clipping copy then have their cached
bounds computed again, as paste already does for pasted type.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-24 21:43:44 +07:00
committed by bflatastic
parent f37d5d5d16
commit 0520e9da86
16 changed files with 565 additions and 26 deletions
+2
View File
@@ -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)
}
+2 -1
View File
@@ -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<Vec<NodeId>> {
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)
}
+3
View File
@@ -323,6 +323,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
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);
+4
View File
@@ -768,6 +768,10 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
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)
+4
View File
@@ -388,6 +388,10 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
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)
})?;
+2
View File
@@ -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)
+2
View File
@@ -237,6 +237,8 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
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(())
})?;
+2
View File
@@ -892,6 +892,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
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<f64> {
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(),
+296 -4
View File
@@ -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) {
+2
View File
@@ -329,6 +329,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
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);