Type: Convert to Text makes a list's bullets and numbers characters of its text

The reference app's Type › Bullets and Numbering › Convert to Text turns
the bullets and numbers of the selected list paragraphs into editable
text. VectorCraft drew list markers but could not convert them.

Type › Bullets and Numbering › Convert to Text (type.convertListToText,
text.convertListToText) puts each list paragraph's marker at the start
of the paragraph as characters: the bullet, or the number and its
period as the paragraph showed it, then a tab, in the marker's font,
size and color. The paragraph leaves its list. Where the marker's tab
went to the left indent, the paragraph gets a plain left tab stop
there, so the text after the tab starts where it did. Where the
marker's tab went to a stop with a leader, or to a right, center or
decimal stop, a plain stop where the tab ended keeps the converted tab
there without the leader; right, center and decimal stops between the
marker and that place are removed. Past the left indent this applies
only to a paragraph with no tab of its own. In threaded type the
numbers are those the story counted, and a paragraph that goes on into
the next frame is converted once, where it starts. A right-to-left
paragraph with no direction set gets the Right to Left direction when a
letter or roman numeral in front of it would make it run left to right.
The change is one undo step. The Type tool's caret stays next to the
character it was next to, and in threaded type it goes with that
character to the frame it moved to, which becomes the selection when it
was not selected.

With the Every-line Composer a paragraph that holds a tab is broken
line by line, so a converted paragraph can break its lines at other
places than the list paragraph did.

Docs: docs/mcp.md, docs/target-app-parity.md.
i18n: Convert to Text in the 24 catalogs.
This commit is contained in:
dotCooCoo
2026-10-26 19:38:12 +07:00
committed by bflatastic
parent 7c13424afc
commit 2f445e117f
30 changed files with 811 additions and 5 deletions
+277 -1
View File
@@ -2,7 +2,7 @@
//! Character/Paragraph attributes.
use serde_json::{Value, json};
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify};
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun};
use vectorcraft_geom::{Rect, Vec2};
use super::*;
@@ -47,6 +47,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_format
),
cmd!(
"text.convertListToText",
"Convert to Text",
[],
None,
"{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}",
has_doc,
convert_list_to_text
),
]
}
@@ -642,3 +651,270 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame,
/// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it
/// and the numbered paragraphs the frame counts before it.
struct ParagraphPart {
frame: usize,
index: usize,
bytes: std::ops::Range<usize>,
list: Option<vectorcraft_doc::text::ParaList>,
marker: Option<String>,
count: u32,
}
/// A paragraph of a story: its parts in thread order (a later frame's first paragraph goes on
/// with the paragraph the frame before it ends in, which may be an empty part after a paragraph
/// break), and whether the call converts it (a part of it is in a paragraph the call names).
struct StoryParagraph {
parts: Vec<ParagraphPart>,
converts: bool,
}
impl StoryParagraph {
/// The part whose style a re-flowed thread gives the whole paragraph
/// (`vectorcraft_text::thread::story_paras`) and whose marker the layout draws: the first part
/// with text, else the last part.
fn main_part(&self) -> Option<&ParagraphPart> {
self.parts.iter().find(|p| !p.bytes.is_empty()).or(self.parts.last())
}
}
/// The style of the character at byte `b` of `t`, as the layout reads it: the run that holds `b`,
/// else the one before it, else the first.
fn char_style_at(t: &vectorcraft_doc::TextObject, b: usize) -> vectorcraft_doc::CharStyle {
let mut runs = vec![];
let mut off = 0;
for r in &t.runs {
runs.push((off..off + r.text.len(), &r.style));
off += r.text.len();
}
let find = |b: usize| runs.iter().find(|(r, _)| r.contains(&b)).map(|(_, s)| (*s).clone());
find(b).or_else(|| b.checked_sub(1).and_then(find)).unwrap_or_else(|| t.first_style())
}
/// The list marker that `lay` (the layout of `t`) draws before paragraph `index` (bytes `bytes`):
/// the width of its characters and the width of the tab after them. A paragraph the layout leaves
/// out (overset text) is laid out on its own, as a paragraph that starts there, numbered on from
/// `count`.
fn marker_extent(
t: &vectorcraft_doc::TextObject,
lay: &vectorcraft_text::TextLayout,
index: usize,
bytes: std::ops::Range<usize>,
count: u32,
) -> (f64, f64) {
let width = |lay: &vectorcraft_text::TextLayout, start: usize| {
lay.markers.iter().find(|m| lay.glyphs.get(m.glyphs.start).is_some_and(|g| g.byte == start)).map(|m| {
let advance = |g: usize| lay.glyphs.get(g).map_or(0.0, |g| g.advance);
(m.glyphs.clone().filter(|&g| g != m.tab).map(advance).sum::<f64>(), advance(m.tab))
})
};
width(lay, bytes.start).unwrap_or_else(|| {
let mut probe = t.clone();
probe.kind = vectorcraft_doc::TextKind::Point;
probe.runs = vectorcraft_text::edit::slice_runs(&t.runs, bytes.start, bytes.end);
probe.ruby.clear();
probe.set_all_paras(t.para_at(index).clone());
probe.list_count = count;
probe.first_para_continues = false;
width(&vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), 0).unwrap_or((0.0, 0.0))
})
}
/// Whether tab stop `tab` is at position `x`.
fn stop_at(tab: &vectorcraft_doc::TabStop, x: f64) -> bool {
(tab.position - x).abs() <= 1e-6
}
/// Whether `tabs` hold one stop at position `x`, left-aligned and without a leader.
fn plain_stop_at(tabs: &[vectorcraft_doc::TabStop], x: f64) -> bool {
let there: Vec<&vectorcraft_doc::TabStop> = tabs.iter().filter(|tab| stop_at(tab, x)).collect();
matches!(there.as_slice(), [only] if only.align == vectorcraft_doc::TabAlign::Left && only.leader.is_empty())
}
/// Whether tab stop `tab` lies between positions `from` and `x` and is not left-aligned.
fn aligned_between(tab: &vectorcraft_doc::TabStop, from: f64, x: f64) -> bool {
tab.align != vectorcraft_doc::TabAlign::Left && tab.position > from + 1e-6 && tab.position < x - 1e-6
}
/// Type › Bullets and Numbering › Convert to Text (`text.convertListToText`): the markers of the
/// list paragraphs the call names become characters of the text, and the lists are turned off.
fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.convertListToText";
use vectorcraft_doc::text::ParaList;
let ids = super::typecmd::text_targets(s, p, C)?;
let range = super::typecmd::TextRange::parse(p, C)?;
let d = &s.doc()?.doc;
let text = |id: NodeId| match d.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.as_ref()),
_ => None,
};
// Each story once: the frames of a thread in thread order, or one type object.
let mut stories: Vec<Vec<NodeId>> = vec![];
for id in &ids {
if stories.iter().any(|story| story.contains(id)) {
continue;
}
let thread = d.text_threads.iter().find(|thread| thread.contains(id));
stories.push(thread.map_or_else(|| vec![*id], |thread| thread.iter().copied().filter(|f| text(*f).is_some()).collect()));
}
// Per frame: the paragraphs whose list goes (with the style each gets) and the characters that
// go in at a byte.
type Stops = Vec<(usize, vectorcraft_doc::ParaStyle)>;
type Inserts = Vec<(usize, TextRun)>;
let mut edits: Vec<(NodeId, Stops, Inserts)> = vec![];
let mut converted = 0;
for story in &stories {
let frames: Vec<&vectorcraft_doc::TextObject> = story.iter().filter_map(|id| text(*id)).collect();
let mut paras: Vec<StoryParagraph> = vec![];
let mut named_parts = vec![];
let plains: Vec<String> = frames.iter().map(|t| t.plain_text()).collect();
for (f, ((id, t), plain)) in story.iter().zip(&frames).zip(&plains).enumerate() {
let named = if ids.contains(id) { range.map_or(0..t.paragraph_count(), |r| r.paras(t)) } else { 0..0 };
let mut count = t.list_count;
let mut start = 0;
for (k, piece) in plain.split('\n').enumerate() {
let bytes = start..start + piece.len();
start = bytes.end + 1;
let before = count;
let list = t.para_at(k).list;
let marker = ParaList::next_marker(list, piece.is_empty(), &mut count);
// A later frame's first paragraph goes on with the one the frame before it ends in.
if !(k == 0 && f > 0) || paras.is_empty() {
paras.push(StoryParagraph { parts: vec![], converts: false });
}
if let Some(para) = paras.last_mut() {
para.parts.push(ParagraphPart { frame: f, index: k, bytes, list, marker, count: before });
named_parts.push((paras.len() - 1, named.contains(&k)));
}
}
}
for (i, named) in named_parts {
if let Some(para) = paras.get_mut(i) {
let listed = para.main_part().is_some_and(|p| p.list.is_some());
para.converts |= named && listed;
}
}
// Each converted paragraph's main part, whether the paragraph holds a tab of its own, and
// the paragraph.
let main_parts: Vec<(&ParagraphPart, bool, &StoryParagraph)> = paras
.iter()
.filter(|p| p.converts)
.filter_map(|p| {
let tabs = p.parts.iter().any(|q| plains.get(q.frame).and_then(|s| s.get(q.bytes.clone())).is_some_and(|s| s.contains('\t')));
p.main_part().map(|m| (m, tabs, p))
})
.collect();
let layouts: Vec<Option<vectorcraft_text::TextLayout>> = frames
.iter()
.enumerate()
.map(|(f, t)| {
main_parts
.iter()
.any(|(p, _, _)| p.marker.is_some() && p.frame == f)
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t))
})
.collect();
let mut frame_edits: Vec<(Stops, Inserts)> = vec![(vec![], vec![]); frames.len()];
for (para, own_tabs, story_para) in main_parts {
converted += 1;
let mut stop = None;
let mut direction = None;
if let (Some(marker), Some(t), Some(Some(lay))) = (&para.marker, frames.get(para.frame), layouts.get(para.frame)) {
// The marker's tab went to the left indent when it was ahead of the marker and no
// explicit stop came first, as the layout sets it; otherwise to the next tab stop,
// without the stop's leader, and for a right, center or decimal stop measured with
// the whole paragraph after it, where a converted tab measures the rest of its line.
// A plain left stop where the marker's tab ended takes the converted tab there: one
// at the left indent in the first case, and in the second, where a stop there or
// past it has a leader or is not left-aligned, or a right, center or decimal stop
// lies between the marker and that place. Those right, center and decimal stops
// between the marker and that place are removed. No other tab reaches a stop before
// the left indent, nor one past it in a paragraph with no tab of its own.
let style = t.para_at(para.index);
let (width, gap) = marker_extent(t, lay, para.index, para.bytes.clone(), para.count);
let pen = style.left_indent + style.first_line_indent + width;
let indent = style.left_indent;
let before = style.tabs.iter().any(|tab| tab.position > pen + 1e-6 && tab.position < indent - 1e-6);
let end = pen + gap;
let needed = |x: f64| !plain_stop_at(&style.tabs, x) || style.tabs.iter().any(|tab| aligned_between(tab, pen, x));
if indent - pen > 1e-6 && !before {
stop = Some((pen, indent)).filter(|&(_, x)| needed(x));
} else if (end < indent - 1e-6 || !own_tabs)
&& style.tabs.iter().any(|tab| {
let past = tab.position > end - 1e-6 && (!tab.leader.is_empty() || tab.align != vectorcraft_doc::TabAlign::Left);
past || aligned_between(tab, pen, end)
})
{
stop = Some((pen, end)).filter(|&(_, x)| needed(x));
}
// A paragraph with no direction set runs the way its first strong character does.
// When the marker's characters would change that (a letter or a roman numeral
// before right-to-left text), the paragraph gets the direction it ran in.
if style.direction.is_none() {
let body = plains.get(para.frame).and_then(|s| s.get(para.bytes.clone())).unwrap_or_default();
let was = vectorcraft_text::paragraph_is_rtl(body, None);
if vectorcraft_text::paragraph_is_rtl(&format!("{marker}\t{body}"), None) != was {
direction = Some(if was { vectorcraft_doc::ParaDirection::RightToLeft } else { vectorcraft_doc::ParaDirection::LeftToRight });
}
}
let run = TextRun { text: format!("{marker}\t"), style: ParaList::marker_style(&char_style_at(t, para.bytes.start)), inline: None };
if let Some((_, inserts)) = frame_edits.get_mut(para.frame) {
inserts.push((para.bytes.start, run));
}
}
// A paragraph in several frames is changed in its main part and in its empty parts:
// re-flowing the thread gives the other parts with text the main part's style.
let Some(mut style) = frames.get(para.frame).map(|t| t.para_at(para.index).clone()) else { continue };
style.list = None;
if direction.is_some() {
style.direction = direction;
}
// A plain left stop at `x`, in place of any stop there and of the right, center and
// decimal stops between the marker's end `from` and `x`.
if let Some((from, x)) = stop {
style.tabs.retain(|tab| !stop_at(tab, x) && !aligned_between(tab, from, x));
let i = style.tabs.iter().position(|tab| tab.position > x).unwrap_or(style.tabs.len());
let tab = vectorcraft_doc::TabStop { position: x, align: vectorcraft_doc::TabAlign::Left, leader: String::new(), align_on: '.' };
style.tabs.insert(i, tab);
}
for part in story_para.parts.iter().filter(|q| std::ptr::eq(*q, para) || q.bytes.is_empty()) {
if let Some((stops, _)) = frame_edits.get_mut(part.frame) {
stops.push((part.index, style.clone()));
}
}
}
for (id, (stops, inserts)) in story.iter().zip(frame_edits) {
if !stops.is_empty() {
edits.push((*id, stops, inserts));
}
}
}
let changed: Vec<u64> = edits.iter().map(|(id, _, _)| id.0).collect();
let inserted: Vec<Value> = edits
.iter()
.flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| json!({"id": id.0, "at": at, "length": run.text.len()})))
.collect();
if !edits.is_empty() {
s.edit("Convert to Text", |d, _| {
for (id, stops, mut inserts) in edits {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
for (k, style) in stops {
t.edit_paras(Some(k..k + 1), |para| *para = style.clone());
}
// From the last paragraph to the first, so that each byte still names its place.
inserts.sort_by_key(|(at, _)| std::cmp::Reverse(*at));
for (at, run) in inserts {
let len = run.text.len();
vectorcraft_text::edit::replace_range_styled(&mut t.runs, at, at, &[run]);
t.ruby_after_replace(at, at, len);
}
super::typecmd::refresh_bounds(t);
}
Ok(())
})?;
}
Ok(json!({ "ids": changed, "converted": converted, "inserted": inserted }))
}
+310
View File
@@ -1657,6 +1657,316 @@ mod area_tests {
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
}
/// The glyphs the layout of type `id` draws, each with its glyph, where it is drawn and the
/// transform it is drawn with (in thousandths), from the top line down and left to right.
fn drawn_glyphs(s: &Session, id: u64) -> Vec<(u32, i64, i64, [i64; 6])> {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let milli = |v: f64| (v * 1000.0).round() as i64;
let mut v: Vec<(u32, i64, i64, [i64; 6])> = lay
.glyphs
.iter()
.filter(|g| !g.outline.elements().is_empty())
.map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y), g.xf.as_coeffs().map(milli)))
.collect();
v.sort_by_key(|&(_, x, y, _)| (y, x));
v
}
/// Type › Bullets and Numbering › Convert to Text: `text.convertListToText` writes each list
/// paragraph's marker before its text as characters in the marker's style, followed by a tab,
/// turns the list off, and gives the paragraph a plain left tab stop at the left indent where
/// the marker's tab went to it, so every glyph is drawn where it was: bullets in area type (a
/// wrapped item, an empty item, a right-aligned stop at the indent and a stop before it, both
/// with leaders), numbers narrower and wider than the hanging indent (one reaching a stop with
/// a leader past it), and point and vertical type. A paragraph with a tab of its own keeps its
/// stops past the indent. The characters can be styled and deleted, and one undo brings the
/// lists back.
#[test]
fn convert_to_text_writes_the_markers_where_they_were_drawn() {
use vectorcraft_doc::text::{Bullet, ParaList};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
let markers = |s: &Session, id: u64| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.len();
let tabs =
|s: &Session, id: u64, k: usize| text(s, id).para_at(k).tabs.iter().map(|t| (t.position, t.align, t.leader.clone())).collect::<Vec<_>>();
let long = "The quick brown fox jumps over the lazy dog and runs far away into the woods beyond the hill";
let id = s
.execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\n{long}\n\nFour\nFive"), "area": {"width": 200, "height": 400}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let (four, five) = (long.len() + 6, long.len() + 11);
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "bullet": "square", "start": four, "end": four})).unwrap();
// A right-aligned stop with a leader at the fourth item's left indent, and a stop with a
// leader before the fifth item's: the bullets' tabs go to the indent and to that stop and
// draw no leader.
s.execute("text.tabs.set", &json!({"ids": [id], "start": four, "end": four, "stops": [{"position": 18, "align": "right", "leader": "."}]}))
.unwrap();
s.execute("text.tabs.set", &json!({"ids": [id], "start": five, "end": five, "stops": [{"position": 10, "leader": "."}]})).unwrap();
// The first character larger, raised and underlined: the bullet takes its size alone. The
// fourth item's first character is larger than the break before it.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "size": 20, "baselineShift": 4, "underline": true})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id, "start": four, "end": four + 1, "size": 20})).unwrap();
let listed = text(&s, id);
let before = drawn_glyphs(&s, id);
assert_eq!(markers(&s, id), 4, "the empty item has no bullet");
let reply = s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
let inserted = |at: usize| json!({"id": id, "at": at, "length": 4});
assert_eq!(reply, json!({"ids": [id], "converted": 5, "inserted": [inserted(0), inserted(4), inserted(four), inserted(five)]}));
assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was");
let t = text(&s, id);
assert_eq!(t.plain_text(), format!("\u{2022}\tOne\n\u{2022}\t{long}\n\n\u{25AA}\tFour\n\u{2022}\tFive"));
assert!((0..5).all(|k| t.para_at(k).list.is_none()), "no paragraph is in a list");
assert_eq!(markers(&s, id), 0);
let left = vectorcraft_doc::TabAlign::Left;
let plain_stop = |position| vec![(position, left, String::new())];
assert_eq!(
(tabs(&s, id, 0), tabs(&s, id, 1), tabs(&s, id, 2), tabs(&s, id, 3), tabs(&s, id, 4)),
(plain_stop(18.0), plain_stop(18.0), vec![], plain_stop(18.0), plain_stop(10.0)),
"a plain left stop at the left indent (in place of the right-aligned one), none in the empty item, and the stop the \
fifth item's tab went to without its leader"
);
let bullet = &t.runs[0];
assert_eq!(
(bullet.text.as_str(), bullet.style.size, bullet.style.baseline_shift, bullet.style.underline),
("\u{2022}\t", 20.0, 0.0, false),
"the bullet has the first character's size without its baseline shift and underline"
);
// The characters are text: they take a style and can be deleted.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 30})).unwrap();
assert_eq!(text(&s, id).runs[0].style.size, 30.0);
s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 4, "insert": ""})).unwrap();
assert!(text(&s, id).plain_text().starts_with("One\n\u{2022}\t"));
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
let back = text(&s, id);
assert_eq!(back.para_at(3).list, Some(ParaList::bulleted(Bullet::Square)));
assert_eq!((back.runs, back.para, back.paras), (listed.runs, listed.para, listed.paras), "one undo step");
assert_eq!(drawn_glyphs(&s, id), before);
// Numbers 1 to 9 at 20 pt are narrower than the hanging indent, 10 is wider and takes the
// text to the next default stop, and 11 to its stop at 72 pt, whose leader the number's tab
// draws neither before nor after.
let items = (1..=11).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
let id =
s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
let eleven = 9 * 7 + 8;
s.execute("text.tabs.set", &json!({"ids": [id], "start": eleven, "end": eleven, "stops": [{"position": 72, "leader": "."}]})).unwrap();
let before = drawn_glyphs(&s, id);
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!(drawn_glyphs(&s, id), before, "numbers");
let expected = (1..=11).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n");
assert_eq!(text(&s, id).plain_text(), expected);
assert_eq!((tabs(&s, id, 0), tabs(&s, id, 9), tabs(&s, id, 10)), (plain_stop(18.0), vec![], plain_stop(72.0)));
// A paragraph with a tab of its own keeps its stops past the left indent, which that tab
// can reach.
let id = s.execute("text.create", &json!({"x": 10, "y": 650, "size": 30, "text": "Item\tvalue"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 72, "leader": "."}, {"position": 200}]})).unwrap();
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!(text(&s, id).plain_text(), "1.\tItem\tvalue");
assert_eq!(tabs(&s, id, 0), [(72.0, left, ".".to_string()), (200.0, left, String::new())]);
// A 30 pt number's tab to a right-aligned stop aligns the whole paragraph there, trailing
// space included, and a converted tab aligns the rest of its line: a plain left stop where
// the number's tab ended keeps the text in place. A right-aligned stop before the left stop
// the number's tab reached, which the wrapped paragraph's width kept the number's tab from,
// would catch the converted tab, which measures only the first line: it goes. The lines are
// compared with the Single-line Composer, as a paragraph with a tab is broken line by line.
let right = vectorcraft_doc::TabAlign::Right;
for (text_in, width, stops, kept) in [
("One ", 300, json!([{"position": 200, "align": "right"}]), vec![(200.0, right, String::new())]),
("The quick brown fox jumps over the lazy dog", 230, json!([{"position": 100, "align": "right"}, {"position": 150}]), vec![]),
] {
let id = s
.execute("text.create", &json!({"x": 10, "y": 600, "size": 30, "text": text_in, "area": {"width": width, "height": 300}}))
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "number", "composer": "singleLine"})).unwrap();
s.execute("text.tabs.set", &json!({"ids": [id], "stops": stops})).unwrap();
let before = drawn_glyphs(&s, id);
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!(drawn_glyphs(&s, id), before, "{text_in:?}");
let after = tabs(&s, id, 0);
assert_eq!(
(after.first().map(|t| (t.1, t.2.as_str())), after.get(1..).map(<[_]>::to_vec)),
(Some((left, "")), Some(kept)),
"{text_in:?}"
);
}
// Overset items, which the layout leaves out, get the stop by the same rule: a 30 pt number is
// wider than the hanging indent, a 12 pt bullet is not.
for (size, list, text_after, stop) in
[(30, "number", "1.\tOne\n2.\tTwo", vec![]), (12, "bullet", "\u{2022}\tOne\n\u{2022}\tTwo", plain_stop(18.0))]
{
let id = s
.execute("text.create", &json!({"x": 10, "y": 500, "size": size, "text": "One\nTwo", "area": {"width": 200, "height": size + 10}}))
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": list})).unwrap();
assert_eq!(markers(&s, id), 1, "the second item is overset");
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!((text(&s, id).plain_text().as_str(), tabs(&s, id, 1)), (text_after, stop), "{size} pt");
}
// Point type and vertical type.
for vertical in [false, true] {
let id =
s.execute("text.create", &json!({"x": 400, "y": 700, "vertical": vertical, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
let before = drawn_glyphs(&s, id);
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!(drawn_glyphs(&s, id), before, "vertical {vertical}");
assert_eq!(text(&s, id).plain_text(), "1.\tOne\n2.\tTwo");
}
// Type with no list paragraph: nothing to convert and no undo step.
let plain = s.execute("text.create", &json!({"x": 10, "y": 700, "text": "Plain"})).unwrap()["id"].as_u64().unwrap();
let steps = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("text.convertListToText", &json!({"ids": [plain]})).unwrap(), json!({"ids": [], "converted": 0, "inserted": []}));
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
/// A right-to-left list paragraph converted to text starts with the number, its period and a
/// tab, which are read first: the line draws its glyphs in the order it drew them with the
/// marker, the number at the right with its period at its left. Letters and roman numerals
/// would make the paragraph run left to right, so the paragraph gets the direction it ran in;
/// digits leave the direction unset.
#[test]
fn a_right_to_left_list_paragraph_converts_to_text_read_first() {
use vectorcraft_doc::ParaDirection;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
for (number, text, direction) in [
("arabic", "1.\tשלום\n2.\tעולם", None),
("upperLetters", "A.\tשלום\nB.\tעולם", Some(ParaDirection::RightToLeft)),
("upperRoman", "I.\tשלום\nII.\tעולם", Some(ParaDirection::RightToLeft)),
] {
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "שלום\nעולם", "area": {"width": 200, "height": 200}})).unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "number": number})).unwrap();
// `drawn_glyphs` lists the first line first, from left to right.
let order = |s: &Session| {
let glyphs = drawn_glyphs(s, id);
let top = glyphs.first().map_or(0, |g| g.2);
glyphs.into_iter().filter(|g| g.2 == top).map(|g| g.0).collect::<Vec<_>>()
};
let before = order(&s);
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
assert_eq!(t.plain_text(), text);
assert!(t.para_at(0).list.is_none() && t.para_at(1).list.is_none());
let directions = (t.para_at(0).direction, t.para_at(1).direction);
assert_eq!(order(&s), before, "{number}: the first line's glyphs from left to right");
assert_eq!(directions, (direction, direction), "{number}");
}
}
/// Convert to Text in threaded type writes the numbers the story counted through its frames,
/// and both frames draw every glyph where it was. A paragraph that continues into the next
/// frame gets its number once, where it starts, also when only the later frame is named, and
/// empty frames after a converted paragraph leave its list with it.
#[test]
fn threaded_numbers_convert_to_text_as_the_story_counted_them() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("text"),
};
let numbers = |s: &Session, id: u64| {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
};
let thread = |s: &mut Session, text: &str| {
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
(a, b)
};
// Whole paragraphs: the first frame ends with a paragraph break.
let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n"));
let first = numbers(&s, a).len();
assert!((2..10).contains(&first) && plain(&s, a).ends_with('\n'), "both frames hold items: {first}");
let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b));
let reply = s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap();
assert_eq!(reply["converted"], 10, "each paragraph once");
assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames");
let story = (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n");
assert_eq!(plain(&s, a) + &plain(&s, b), story);
assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}", n = first + 1)));
// The empty part after the first frame's last break and the second frame's first item are
// one paragraph: a caret at the end of the first frame converts that item.
let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n"));
let end = plain(&s, a).len();
let reply = s.execute("text.convertListToText", &json!({"ids": [a], "start": end, "end": end})).unwrap();
assert_eq!(reply["converted"], 1);
assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}\nItem", n = first + 1)), "{}", plain(&s, b));
// A paragraph split between the frames, with only the second frame named.
let long = "The quick brown fox jumps over the lazy dog. ".repeat(4);
let (a, b) = thread(&mut s, &format!("One\nTwo\n{long}\nFour"));
assert!(!plain(&s, a).ends_with('\n'), "the third paragraph goes on in the second frame");
// A paragraph with a tab is broken line by line, so the lines are compared with the
// Single-line Composer, which breaks the listed paragraph the same way.
s.execute("text.setFormat", &json!({"ids": [a, b], "composer": "singleLine"})).unwrap();
let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b));
s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap();
assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames, with the paragraph split between them");
assert_eq!(plain(&s, a) + &plain(&s, b), format!("One\nTwo\n3.\t{long}\n4.\tFour"));
assert_eq!(numbers(&s, a), ["1.", "2."], "the paragraphs of the first frame alone stay in the list");
let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
assert!(second.para_at(0).list.is_none(), "the third paragraph's part in the second frame leaves the list too");
// One item that fits in the first of three frames: the two empty frames after it leave the
// list too, so the re-flowed thread draws no number before the converted one.
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Item", "area": {"width": 150, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b, c], "list": "number"})).unwrap();
assert_eq!((plain(&s, b), plain(&s, c), numbers(&s, a)), (String::new(), String::new(), vec!["1.".to_string()]));
assert_eq!(s.execute("text.convertListToText", &json!({"ids": [a]})).unwrap()["converted"], 1);
assert_eq!(plain(&s, a), "1.\tItem");
assert_eq!(numbers(&s, a), Vec::<String>::new(), "no number drawn before the converted one");
for id in [a, b, c] {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
assert!(t.para_at(0).list.is_none(), "frame {id} leaves the list");
}
// An overset 30 pt number, wider than the hanging indent, in a last frame that starts
// inside a paragraph: its tab goes to the next stop, so it gets no stop at the left indent.
let a = s
.execute("text.create", &json!({"x": 10, "y": 10, "size": 30, "text": format!("{long}\nTwo"), "area": {"width": 150, "height": 80}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 80})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
assert!(second.first_para_continues && second.plain_text().ends_with("\nTwo"), "{:?}", second.plain_text());
assert_eq!(numbers(&s, b).len(), 0, "the second item is overset");
s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap();
let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
assert!(second.plain_text().ends_with("\n2.\tTwo"), "{:?}", second.plain_text());
assert!(second.para_at(1).tabs.is_empty(), "no stop at the left indent: {:?}", second.para_at(1).tabs);
}
/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters.
#[test]
fn create_outlines_makes_a_path_of_the_bullet() {
+1
View File
@@ -343,6 +343,7 @@ font-technology Variable Променливи
Apply Bullets Прилагане на булети
Numbered List Номериран списък
Apply Numbers Прилагане на номериране
Convert to Text Конвертиране в текст
Filled Dot Плътна точка
Dash Тире
Hollow Circle Празен кръг
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -327,6 +327,7 @@
Apply Bullets Použít odrážky
Numbered List Číslovaný seznam
Apply Numbers Použít číslování
Convert to Text Převést na text
Filled Dot Plná tečka
Dash Pomlčka
Hollow Circle Prázdný kruh
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -289,6 +289,7 @@
Apply Bullets Anvend kugletegn
Numbered List Nummereret liste
Apply Numbers Anvend nummerering
Convert to Text Konvertér til tekst
Filled Dot Udfyldt prik
Dash Tankestreg
Hollow Circle Tom cirkel
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -331,6 +331,7 @@ font-technology Variable Variabel
Apply Bullets Aufzählungszeichen anwenden
Numbered List Nummerierte Liste
Apply Numbers Nummerierung anwenden
Convert to Text In Text umwandeln
Filled Dot Gefüllter Punkt
Dash Strich
Hollow Circle Leerer Kreis
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -331,6 +331,7 @@ font-technology Variable Μεταβλητές
Apply Bullets Εφαρμογή κουκκίδων
Numbered List Αριθμημένη λίστα
Apply Numbers Εφαρμογή αρίθμησης
Convert to Text Μετατροπή σε κείμενο
Filled Dot Γεμάτη κουκκίδα
Dash Παύλα
Hollow Circle Κενός κύκλος
1 # Ελληνικά (el) — VectorCraft
331
332
333
334
335
336
337
+1
View File
@@ -356,6 +356,7 @@ font-technology Variable Variables
Apply Bullets Aplicar viñetas
Numbered List Lista numerada
Apply Numbers Aplicar numeración
Convert to Text Convertir en texto
Filled Dot Punto relleno
Dash Guion
Hollow Circle Círculo vacío
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -324,6 +324,7 @@
Apply Bullets Käytä luettelomerkkejä
Numbered List Numeroitu luettelo
Apply Numbers Käytä numerointia
Convert to Text Muunna tekstiksi
Filled Dot Täytetty piste
Dash Ajatusviiva
Hollow Circle Ontto ympyrä
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -357,6 +357,7 @@ font-technology Variable Variables
Apply Bullets Appliquer les puces
Numbered List Liste numérotée
Apply Numbers Appliquer la numérotation
Convert to Text Convertir en texte
Filled Dot Point plein
Dash Tiret
Hollow Circle Cercle vide
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -492,6 +492,7 @@ font-technology Variable Változó
Apply Bullets Felsorolásjelek alkalmazása
Numbered List Számozott lista
Apply Numbers Számozás alkalmazása
Convert to Text Átalakítás szöveggé
Filled Dot Kitöltött pont
Dash Gondolatjel
Hollow Circle Üres kör
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -289,6 +289,7 @@
Apply Bullets Terapkan poin
Numbered List Daftar bernomor
Apply Numbers Terapkan penomoran
Convert to Text Konversi ke teks
Filled Dot Titik penuh
Dash Tanda pisah
Hollow Circle Lingkaran kosong
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
289
290
291
292
293
294
295
+1
View File
@@ -356,6 +356,7 @@ font-technology Variable Variabili
Apply Bullets Applica punti elenco
Numbered List Elenco numerato
Apply Numbers Applica numerazione
Convert to Text Converti in testo
Filled Dot Punto pieno
Dash Trattino
Hollow Circle Cerchio vuoto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -317,6 +317,7 @@ font-technology Variable バリアブル
Apply Bullets 箇条書きを適用
Numbered List 番号付きリスト
Apply Numbers 番号付けを適用
Convert to Text テキストに変換
Filled Dot 黒丸
Dash ダッシュ
Hollow Circle 白丸
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -248,6 +248,7 @@
Apply Bullets ბურთულების გამოყენება
Numbered List დანომრილი სია
Apply Numbers დანომრვის გამოყენება
Convert to Text ტექსტად გარდაქმნა
Filled Dot სავსე წერტილი
Dash ტირე
Hollow Circle ცარიელი წრე
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -313,6 +313,7 @@ font-technology Variable 가변
Apply Bullets 글머리 기호 적용
Numbered List 번호 매기기 목록
Apply Numbers 번호 매기기 적용
Convert to Text 텍스트로 변환
Filled Dot 채운 점
Dash 대시
Hollow Circle 빈 원
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -303,6 +303,7 @@
Apply Bullets Opsommingstekens toepassen
Numbered List Genummerde lijst
Apply Numbers Nummering toepassen
Convert to Text Converteren naar tekst
Filled Dot Gevulde stip
Dash Streepje
Hollow Circle Holle cirkel
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -288,6 +288,7 @@
Apply Bullets Zastosuj punktory
Numbered List Lista numerowana
Apply Numbers Zastosuj numerację
Convert to Text Konwertuj na tekst
Filled Dot Pełna kropka
Dash Myślnik
Hollow Circle Pusty okrąg
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -326,6 +326,7 @@
Apply Bullets Aplicar Marcadores
Numbered List Lista Numerada
Apply Numbers Aplicar Numeração
Convert to Text Converter em Texto
Filled Dot Ponto Preenchido
Dash Travessão
Hollow Circle Círculo Vazado
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -354,6 +354,7 @@ font-technology Variable Вариативные
Apply Bullets Применить маркеры
Numbered List Нумерованный список
Apply Numbers Применить нумерацию
Convert to Text Преобразовать в текст
Filled Dot Закрашенная точка
Dash Тире
Hollow Circle Пустой круг
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -345,6 +345,7 @@ font-technology Variable Variabla
Apply Bullets Använd punkter
Numbered List Numrerad lista
Apply Numbers Använd numrering
Convert to Text Konvertera till text
Filled Dot Fylld punkt
Dash Tankstreck
Hollow Circle Tom cirkel
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -340,6 +340,7 @@ font-technology Variable Değişken
Apply Bullets Madde İşaretlerini Uygula
Numbered List Numaralı Liste
Apply Numbers Numaralandırmayı Uygula
Convert to Text Metne Dönüştür
Filled Dot Dolu Nokta
Dash Tire
Hollow Circle Boş Daire
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -350,6 +350,7 @@ font-technology Variable Варіативні
Apply Bullets Застосувати маркери
Numbered List Нумерований список
Apply Numbers Застосувати нумерацію
Convert to Text Перетворити на текст
Filled Dot Заповнена крапка
Dash Тире
Hollow Circle Порожнє коло
1 # Ukrainian (uk) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation.
350
351
352
353
354
355
356
+1
View File
@@ -410,6 +410,7 @@ font-technology Variable Biến thiên
Apply Bullets Áp dụng dấu đầu dòng
Numbered List Danh sách đánh số
Apply Numbers Áp dụng đánh số
Convert to Text Chuyển thành văn bản
Filled Dot Chấm đặc
Dash Gạch ngang
Hollow Circle Hình tròn rỗng
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
410
411
412
413
414
415
416
+1
View File
@@ -305,6 +305,7 @@
Apply Bullets 应用项目符号
Numbered List 编号列表
Apply Numbers 应用编号
Convert to Text 转换为文本
Filled Dot 实心圆点
Dash 短划线
Hollow Circle 空心圆
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -318,6 +318,7 @@ font-technology Variable 可變
Apply Bullets 套用項目符號
Numbered List 編號清單
Apply Numbers 套用編號
Convert to Text 轉換為文字
Filled Dot 實心圓點
Dash 短破折號
Hollow Circle 空心圓
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16 -2
View File
@@ -252,6 +252,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off",
),
(
"type.convertListToText",
"Convert to Text",
"",
"{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type",
),
(
"align.horizontalLeft",
"Horizontal Align Left",
@@ -1465,6 +1471,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
}
"type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p),
"type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p),
"type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app),
id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b),
"view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator),
"view.livePaintGaps" => flag(&mut app.ui.view.live_paint_gaps),
@@ -2572,7 +2579,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()),
"type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(),
id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(),
"type.applyBullets" | "type.applyNumbers" => crate::panels::character::text_style(app).is_some(),
"type.applyBullets" | "type.applyNumbers" | "type.convertListToText" => crate::panels::character::text_style(app).is_some(),
id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()),
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
"file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()),
@@ -3069,7 +3076,14 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
],
),
cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})),
sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")]),
sub(
"Bullets and Numbering",
vec![
c("Apply Bullets", "type.applyBullets"),
c("Apply Numbers", "type.applyNumbers"),
c("Convert to Text", "type.convertListToText"),
],
),
Sep,
c("Create Outlines", "type.createOutlines"),
c("Scale Text Attributes", "type.scaleTextAttributes"),
+154 -1
View File
@@ -7,7 +7,7 @@
use egui::{Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{
Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, Mojikumi, NodeKind, ParaDirection, ParaStyle, SingleWordJustify,
Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, Mojikumi, NodeId, NodeKind, ParaDirection, ParaStyle, SingleWordJustify,
};
use super::character::{Metric, Shared, text_style};
@@ -358,6 +358,85 @@ pub(crate) fn apply_numbers(app: &mut VectorcraftApp, p: &Value) -> Result<Value
run_on_paragraphs(app, "text.setFormat", params)
}
/// The frames of the story that type object `id` belongs to (its thread in thread order, else `id`
/// alone), each with the length of its text in bytes.
fn story_frames(app: &VectorcraftApp, id: NodeId) -> Vec<(NodeId, usize)> {
let Some(st) = app.session.active() else { return vec![] };
let d = &st.doc;
let frames = d.text_threads.iter().find(|thread| thread.contains(&id)).cloned().unwrap_or_else(|| vec![id]);
frames
.into_iter()
.filter_map(|f| match d.node(f).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some((f, t.plain_text().len())),
_ => None,
})
.collect()
}
/// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the
/// list paragraphs of the selected type, or of those the Type tool's selection touches, become
/// characters of the text and the lists are turned off (`text.convertListToText`). The Type
/// tool's anchor and caret move past the characters inserted at or before them in the story. In
/// threaded type the caret stays in the frame it was in while its place is still there, and else
/// goes to the frame its character moved to, with the anchor in that frame; that frame becomes the
/// selection when it was not selected.
pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result<Value, String> {
text_style(app).ok_or("select some type first")?;
let editing = super::character::text_editing(app).map(|(id, _, _)| id);
let options = app.session.tool_options();
let offset = |k: &str| options.get(k).and_then(Value::as_u64).map(|v| v as usize);
let (anchor, caret) = (offset("anchor"), offset("caret"));
let before = editing.map(|id| story_frames(app, id)).unwrap_or_default();
let reply = run_on_paragraphs(app, "text.convertListToText", json!({}))?;
let (Some(id), Some(anchor), Some(caret)) = (editing, anchor, caret) else { return Ok(reply) };
// Story positions: the bytes of the frames before a frame, then the byte in it.
let start_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().take_while(|(g, _)| *g != f).map(|(_, n)| n).sum::<usize>();
let len_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().find(|(g, _)| *g == f).map_or(0, |(_, n)| *n);
let inserted: Vec<(usize, usize)> = reply["inserted"]
.as_array()
.into_iter()
.flatten()
.filter_map(|i| {
let frame = NodeId(i["id"].as_u64()?);
before.iter().any(|(g, _)| *g == frame).then_some(())?;
Some((start_of(&before, frame) + usize::try_from(i["at"].as_u64()?).ok()?, usize::try_from(i["length"].as_u64()?).ok()?))
})
.collect();
let story = |at: usize| {
let at = start_of(&before, id) + at.min(len_of(&before, id));
at + inserted.iter().filter(|(b, _)| *b <= at).map(|(_, n)| n).sum::<usize>()
};
let (anchor, caret) = (story(anchor), story(caret));
let after = story_frames(app, id);
let fits = |f: NodeId| (start_of(&after, f)..=start_of(&after, f) + len_of(&after, f)).contains(&caret);
let frame = if fits(id) { Some(id) } else { after.iter().map(|(f, _)| *f).find(|f| fits(*f)) };
let Some(frame) = frame else { return Ok(reply) };
let (start, len) = (start_of(&after, frame), len_of(&after, frame));
let local = |at: usize| at.saturating_sub(start).min(len);
if frame == id {
app.session.set_tool_option("select", &json!({"start": local(anchor), "end": local(caret)}));
} else {
app.session.set_tool_option("textSelection", &json!({"editing": frame.0, "anchor": local(anchor), "caret": local(caret)}));
// The Type tool ends editing after a command when the frame it edits is not selected,
// itself or through a selected group, so that frame becomes the selection, as a click in it
// does.
let selected = app.session.active().is_some_and(|st| {
let mut cur = Some(frame);
while let Some(c) = cur {
if st.selection.contains(c) {
return true;
}
cur = st.doc.parent_of(c);
}
false
});
if !selected {
app.run("select.set", json!({"ids": [frame.0]}))?;
}
}
Ok(reply)
}
/// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs
/// the panel acts on a bulleted list (`text.setFormat {list: "bullet"}`) and shows pressed while
/// they all are one. The arrow lists the presets (`{bullet}`): None turns the list off, and the
@@ -1172,6 +1251,80 @@ mod tests {
assert_eq!(lists(&app), (None, None));
}
/// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`) acts on the
/// paragraphs the Type tool's selection touches: their numbers become characters of the text
/// and their list goes, while the other paragraphs stay in the list, and the Type tool's caret
/// stays next to the characters it was next to. The menu item is there and is enabled while
/// type is selected or edited.
#[test]
fn convert_to_text_makes_the_selected_paragraphs_numbers_characters() {
use vectorcraft_doc::text::{NumberStyle, ParaList};
crate::i18n::set_current(crate::i18n::Lang::EN);
let (mut app, id) = editing_text("One\nTwo");
let text = |app: &VectorcraftApp| {
let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap();
let NodeKind::Text(text) = &node.kind else { panic!("text") };
(text.plain_text(), text.para_at(0).list, text.para_at(1).list)
};
app.session.set_tool_option("selectAll", &json!(true));
app.run("type.applyNumbers", json!({})).unwrap();
let numbered = Some(ParaList::numbered(NumberStyle::Arabic));
assert_eq!(text(&app), ("One\nTwo".to_string(), numbered, numbered));
assert!(crate::menus::enabled(&app, "type.convertListToText"));
select_range(&mut app, 5, 5);
app.run("type.convertListToText", json!({})).unwrap();
assert_eq!(text(&app), ("One\n2.\tTwo".to_string(), numbered, None), "the caret's paragraph");
let options = app.session.tool_options();
assert_eq!((options["anchor"].as_u64(), options["caret"].as_u64()), (Some(8), Some(8)), "the caret stays after the T of Two");
let typemenu = crate::menus::menu_tree().into_iter().find(|(title, _)| *title == "Type").map(|(_, items)| items).unwrap_or_default();
let found = typemenu.iter().any(|item| {
matches!(item, crate::menus::Item::Sub("Bullets and Numbering", items)
if items.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Convert to Text", "type.convertListToText", _))))
});
assert!(found, "Type › Bullets and Numbering › Convert to Text");
}
/// Convert to Text in threaded type: when the converted paragraph breaks its lines at other
/// places and its text moves to the frame before, the Type tool's caret goes with the
/// character it was next to, and that frame becomes the selection when it was not selected.
#[test]
fn convert_to_text_keeps_the_caret_by_its_character_in_threaded_type() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
let long = "The quick brown fox jumps over the lazy dog. ".repeat(4);
let text = format!("One\nTwo\n{long}\nFour");
let a =
app.run("text.create", json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
let b = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("text.thread.create", json!({})).unwrap();
app.run("text.setFormat", json!({"ids": [a, b], "list": "number"})).unwrap();
let plain = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("text"),
};
assert!(plain(&app, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&app, b));
app.run("select.set", json!({"ids": [b]})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
// The caret after the d of dog, at the start of the second frame.
app.session.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1}));
assert_eq!(super::super::character::text_editing(&app).map(|(id, _, _)| id), Some(NodeId(b)));
app.run("type.convertListToText", json!({})).unwrap();
let first = plain(&app, a);
assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}");
let options = app.session.tool_options();
let after_d = first.len() - "og. ".len();
assert_eq!(
(options["editing"].as_u64(), options["anchor"].as_u64(), options["caret"].as_u64()),
(Some(a), Some(after_d as u64), Some(after_d as u64)),
"the caret is after the d of dog in the first frame"
);
assert_eq!(app.session.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected");
// The next command keeps the Type tool editing the first frame.
app.run("text.setFormat", json!({"ids": [a], "composer": "singleLine"})).unwrap();
assert_eq!(super::super::character::text_editing(&app).map(|(id, _, _)| id), Some(NodeId(a)));
}
/// Reset Panel turns Hyphenate and Roman Hanging Punctuation off and sets the Hyphenation and
/// Justification options back to their defaults in every paragraph it acts on, so the paragraphs
/// save neither.
+28 -1
View File
@@ -4542,6 +4542,33 @@ Type › Bullets and Numbering › Apply Bullets and the canvas context menu's B
same menus runs `type.applyNumbers {number?}`. Without `number` it runs `text.setFormat {list: "number"}`, and with it
it runs `text.setFormat {number}`.
Type › Bullets and Numbering › Convert to Text runs `type.convertListToText` on the selected type's paragraphs, or on
those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. The marker of
each list paragraph becomes characters at the start of the paragraph: the bullet, or the number and its period as the
paragraph showed it, then a tab. They are set in the marker's font, size and color: the style of the paragraph's first
character without the attributes the marker leaves out. The paragraph leaves its list. When the marker's tab went to
the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that
the text after the tab starts where it started after the marker. When the marker's tab went to a tab stop, the
converted tab goes to the same place when that stop is left-aligned and no stop there or past it has a leader. When a
stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker
and the place its tab ended, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in
place of any stop there and of those right, center and decimal stops before it. A paragraph that holds a tab of its
own keeps its stops past the left indent as they are. The characters are text like any other: they can be selected,
styled, edited and deleted, and copied text includes them. In threaded type the numbers are the ones the story counted
through its frames, and a paragraph that continues into the next frame gets its characters once, where its text
starts. An empty list paragraph has no marker and only leaves the list. A numbered paragraph after the converted ones
that stays in its list counts from 1 again, as after any paragraph in no list. A paragraph with no direction set runs
the way its first strong character does; when a letter or a roman numeral put before right-to-left text would make it
run left to right, the paragraph gets the Right to Left direction. A right-to-left paragraph that leaves its list no
longer has its indents mirrored, and on a path the tab takes its own width, as every tab on a path does. With the
Every-line Composer, a paragraph that holds a tab is broken line by line, so a converted paragraph of several lines
can break at other places than its list paragraph did. The change is one undo step, and the reply gives the `ids` of
the type it changed, how many paragraphs were `converted`, and the characters `inserted`, each with the type object's
`id`, the byte `at` which they went in (counted in the text before the change) and their `length` in bytes. While the
Type tool edits, `type.convertListToText` moves its anchor and caret past the characters inserted at or before them.
In threaded type, when the caret's character moved to another frame, the Type tool edits that frame with the caret
next to the same character, and that frame becomes the selection when it was not selected.
Typing starts a list too. When the Type tool types a space on its own (one key press, or `type_text {text: " "}`) right
after `1.` or `1)`, `A.` or `A)`, or one of `*`, `#`, `>` and `+` at the start of a paragraph that is in no list, the
typed characters and the space are removed and the paragraph becomes a numbered list in 1, 2, 3 or A, B, C, or a
@@ -4554,10 +4581,10 @@ paste, an IME commit, or `type_text` of a longer string. From a script, `text.st
the same with the text before `at` and returns `started` and `caret`. When the list starts, `caret` is the byte where
the paragraph's text now starts; when the text before `at` starts no list, nothing changes, `started` is false and
`caret` is `at`.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"bullet":"square"}}}
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"number":"upperRoman"}}}
{"name":"run_command","arguments":{"command":"text.convertListToText","params":{}}}
```
## Small Caps, Superscript and Subscript
+2
View File
@@ -787,8 +787,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options.
PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths.
The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting.
The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and list detection while typing are not built yet.
The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and Convert to Text are not built yet.
The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1.
Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted.
The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `#`, `>` or `+` makes it a bulleted list with the filled dot. The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing.
The Character panel menu's Character Direction (with the Indic options; `text.setFormat {charDirection}`, `text.setRangeStyle {charDirection}`) sets the selected characters left to right or right to left inside their paragraph, as a Unicode bidi embedding: a date set Left to Right keeps its order in right-to-left text, and a word set Right to Left keeps the punctuation after it on its left. Default (the default) leaves the characters to the bidi algorithm; character styles and the native file keep the setting.
In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X.