From 2f445e117f914b111f346db3d390cfe7e5cdda4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:50:01 -0700 Subject: [PATCH] Type: Convert to Text makes a list's bullets and numbers characters of its text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/engine/src/cmd/panelcmds.rs | 278 +++++++++++++++++++++- crates/engine/src/cmd/typecmd.rs | 310 +++++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 18 +- crates/ui-egui/src/panels/paragraph.rs | 155 ++++++++++++- docs/mcp.md | 29 ++- docs/target-app-parity.md | 2 + 30 files changed, 811 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b2a2aaec9..af5d5dc90 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -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 { 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 { })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } + +/// 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, + list: Option, + marker: Option, + 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, + 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, + 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::(), 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 { + 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![]; + 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 = vec![]; + let mut named_parts = vec![]; + let plains: Vec = 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> = 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))) = (¶.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 = edits.iter().map(|(id, _, _)| id.0).collect(); + let inserted: Vec = 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 })) +} diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 265341495..1dd7288be 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -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::>(); + 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::>().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::>().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::>() + }; + 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::>() + }; + 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::>().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::>().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::>().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::::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() { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1ba33325b..e2abe07bb 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -343,6 +343,7 @@ font-technology Variable Променливи Apply Bullets Прилагане на булети Numbered List Номериран списък Apply Numbers Прилагане на номериране + Convert to Text Конвертиране в текст Filled Dot Плътна точка Dash Тире Hollow Circle Празен кръг diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4198750f4..94505f0af 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 6943c2ed3..608953d0a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ef07db9a1..e74ea83db 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e0041304e..44b29cadd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -331,6 +331,7 @@ font-technology Variable Μεταβλητές Apply Bullets Εφαρμογή κουκκίδων Numbered List Αριθμημένη λίστα Apply Numbers Εφαρμογή αρίθμησης + Convert to Text Μετατροπή σε κείμενο Filled Dot Γεμάτη κουκκίδα Dash Παύλα Hollow Circle Κενός κύκλος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4f0b2400a..a43439c43 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f1ad8a73f..7e597f894 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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ä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8f83aefd7..fc23ddc25 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0f87ca62e..e6aafa67a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 86c55807f..7bdbb9199 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 69320d2ed..b0844bcfa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d7d0aec5c..361bc86ed 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -317,6 +317,7 @@ font-technology Variable バリアブル Apply Bullets 箇条書きを適用 Numbered List 番号付きリスト Apply Numbers 番号付けを適用 + Convert to Text テキストに変換 Filled Dot 黒丸 Dash ダッシュ Hollow Circle 白丸 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4c4d3b09b..f91bdfaa4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -248,6 +248,7 @@ Apply Bullets ბურთულების გამოყენება Numbered List დანომრილი სია Apply Numbers დანომრვის გამოყენება + Convert to Text ტექსტად გარდაქმნა Filled Dot სავსე წერტილი Dash ტირე Hollow Circle ცარიელი წრე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index cffb399e3..5f5f3008e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -313,6 +313,7 @@ font-technology Variable 가변 Apply Bullets 글머리 기호 적용 Numbered List 번호 매기기 목록 Apply Numbers 번호 매기기 적용 + Convert to Text 텍스트로 변환 Filled Dot 채운 점 Dash 대시 Hollow Circle 빈 원 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 902db4c59..0262d3d56 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4ea05872e..04e4a556c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dfb86c5c8..1b0dafbe4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9069e19d3..2943d1fce 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -354,6 +354,7 @@ font-technology Variable Вариативные Apply Bullets Применить маркеры Numbered List Нумерованный список Apply Numbers Применить нумерацию + Convert to Text Преобразовать в текст Filled Dot Закрашенная точка Dash Тире Hollow Circle Пустой круг diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a33e84529..efcb012fb 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1fee86c18..3eee1171c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a94095a55..25854b430 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -350,6 +350,7 @@ font-technology Variable Варіативні Apply Bullets Застосувати маркери Numbered List Нумерований список Apply Numbers Застосувати нумерацію + Convert to Text Перетворити на текст Filled Dot Заповнена крапка Dash Тире Hollow Circle Порожнє коло diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a43d63cce..c06446b9b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 755296033..834da9da9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -305,6 +305,7 @@ Apply Bullets 应用项目符号 Numbered List 编号列表 Apply Numbers 应用编号 + Convert to Text 转换为文本 Filled Dot 实心圆点 Dash 短划线 Hollow Circle 空心圆 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bd5da763a..baf3b3d2d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -318,6 +318,7 @@ font-technology Variable 可變 Apply Bullets 套用項目符號 Numbered List 編號清單 Apply Numbers 套用編號 + Convert to Text 轉換為文字 Filled Dot 實心圓點 Dash 短破折號 Hollow Circle 空心圓 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d79988cb0..afeded96e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 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)> { ], ), 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"), diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index c575b3f25..94df3f05a 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -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 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 { + 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::(); + 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::() + }; + 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. diff --git a/docs/mcp.md b/docs/mcp.md index b9fde2d8e..204ca53e1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 135eca9c9..0657553d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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.