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
+9
View File
@@ -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<Arc<Node>> = std::mem::take(ch);
+7
View File
@@ -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<ParaStyle>,
/// 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(),
+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);
+24 -9
View File
@@ -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<Vec<(usize, bool)>> {
/// 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<Vec<(usize, bool)>> {
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<usize>], 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, &para.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<usize>], 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<usize>], 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<usize>], 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);
+197 -10
View File
@@ -78,6 +78,9 @@ pub struct Slice {
pub runs: Vec<TextRun>,
pub paras: Vec<ParaStyle>,
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<u8>, 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<bool> {
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<Slice> {
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<Slice> {
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::<Vec<_>>()
};
// 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::<Vec<_>>(), [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::<Vec<_>>(),
[false, true, true],
"{:?}",
parts.iter().map(|p| runs_len(&p.runs)).collect::<Vec<_>>()
);
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::<Vec<_>>()), [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(&para));
assert_eq!(parts.iter().map(|p| p.continues).collect::<Vec<_>>(), [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::<Vec<_>>()), [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(&para));
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<TextObject> {
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(&para));
assert_eq!(parts.iter().map(|p| p.continues).collect::<Vec<_>>(), [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]
+8 -2
View File
@@ -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
+1
View File
@@ -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.