mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Merge pull request #4093 from dotCooCoo/feat/list-convert-to-text
Type: Convert to Text makes a list's bullets and numbers characters of its text
This commit is contained in:
@@ -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. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them; in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}",
|
||||
has_doc,
|
||||
convert_list_to_text
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -642,3 +651,355 @@ 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
|
||||
}
|
||||
|
||||
/// 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(s: &Session, id: NodeId) -> Vec<(NodeId, usize)> {
|
||||
let Some(st) = s.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()
|
||||
}
|
||||
|
||||
/// After Convert to Text inserted `inserted` (each a frame, the byte it went in at and its length),
|
||||
/// the Type tool's anchor and caret in frame `id` (`editing`, read before the call, in a story
|
||||
/// whose frames had the lengths `before`) 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.
|
||||
fn move_editing_selection(
|
||||
s: &mut Session,
|
||||
(id, anchor, caret): (NodeId, usize, usize),
|
||||
before: &[(NodeId, usize)],
|
||||
inserted: &[(NodeId, usize, usize)],
|
||||
) -> Result<()> {
|
||||
// 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)> =
|
||||
inserted.iter().filter(|(f, _, _)| before.iter().any(|(g, _)| g == f)).map(|&(f, at, n)| (start_of(before, f) + at, n)).collect();
|
||||
if inserted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
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(s, 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(()) };
|
||||
let (start, len) = (start_of(&after, frame), len_of(&after, frame));
|
||||
let local = |at: usize| at.saturating_sub(start).min(len);
|
||||
if frame == id {
|
||||
s.set_tool_option("select", &json!({"start": local(anchor), "end": local(caret)}));
|
||||
return Ok(());
|
||||
}
|
||||
s.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 = s.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 {
|
||||
s.select(|_, sel| sel.set([frame]))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// While the Type tool edits, its typing ends first and its anchor and caret move past the
|
||||
/// characters inserted at or before them ([`move_editing_selection`]).
|
||||
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 editing = match super::textedit::editing_range(s)? {
|
||||
Some((id, _, _)) => {
|
||||
let options = s.tool_options();
|
||||
let at = |k: &str| options.get(k).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok());
|
||||
at("anchor").zip(at("caret")).map(|(anchor, caret)| (id, anchor, caret))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let story_before = editing.map(|(id, _, _)| story_frames(s, id)).unwrap_or_default();
|
||||
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))) = (¶.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();
|
||||
let insertions: Vec<(NodeId, usize, usize)> =
|
||||
edits.iter().flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| (*id, *at, 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(())
|
||||
})?;
|
||||
if let Some(editing) = editing {
|
||||
move_editing_selection(s, editing, &story_before, &insertions)?;
|
||||
}
|
||||
}
|
||||
Ok(json!({ "ids": changed, "converted": converted, "inserted": inserted }))
|
||||
}
|
||||
|
||||
@@ -1657,6 +1657,369 @@ 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);
|
||||
}
|
||||
|
||||
/// `text.convertListToText` sent while the Type tool edits (as from MCP or the control channel)
|
||||
/// moves the tool's anchor and caret past the characters it inserts at or before them, so that
|
||||
/// neither lands inside a bullet. In threaded type, when the text before the caret moves to the
|
||||
/// frame before, the caret goes with its character into that frame, which becomes the
|
||||
/// selection.
|
||||
#[test]
|
||||
fn convert_to_text_moves_the_type_tools_selection() {
|
||||
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 selection = |s: &Session| {
|
||||
let o = s.tool_options();
|
||||
(o["editing"].as_u64(), o["anchor"].as_u64(), o["caret"].as_u64())
|
||||
};
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.select_tool("type", Default::default()).unwrap();
|
||||
// From after the O of One to after the T of Two.
|
||||
s.set_tool_option("textSelection", &json!({"editing": id, "anchor": 1, "caret": 5}));
|
||||
s.execute("text.convertListToText", &json!({})).unwrap();
|
||||
let text = plain(&s, id);
|
||||
assert_eq!(text, "\u{2022}\tOne\n\u{2022}\tTwo");
|
||||
assert_eq!(selection(&s), (Some(id), Some(5), Some(13)), "the anchor after the O, the caret after the T");
|
||||
assert!(text.is_char_boundary(5) && text.is_char_boundary(13));
|
||||
|
||||
let long = "The quick brown fox jumps over the lazy dog. ".repeat(4);
|
||||
let a = s
|
||||
.execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\nTwo\n{long}\nFour"), "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.select_tool("selection", Default::default()).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();
|
||||
assert!(plain(&s, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&s, b));
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.select_tool("type", Default::default()).unwrap();
|
||||
// The caret after the d of dog, at the start of the second frame.
|
||||
s.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1}));
|
||||
s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap();
|
||||
let first = plain(&s, a);
|
||||
assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}");
|
||||
let after_d = (first.len() - "og. ".len()) as u64;
|
||||
assert_eq!(selection(&s), (Some(a), Some(after_d), Some(after_d)), "the caret is after the d of dog in the first frame");
|
||||
assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected");
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -331,6 +331,7 @@ font-technology Variable Μεταβλητές
|
||||
Apply Bullets Εφαρμογή κουκκίδων
|
||||
Numbered List Αριθμημένη λίστα
|
||||
Apply Numbers Εφαρμογή αρίθμησης
|
||||
Convert to Text Μετατροπή σε κείμενο
|
||||
Filled Dot Γεμάτη κουκκίδα
|
||||
Dash Παύλα
|
||||
Hollow Circle Κενός κύκλος
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -350,6 +350,7 @@ font-technology Variable Варіативні
|
||||
Apply Bullets Застосувати маркери
|
||||
Numbered List Нумерований список
|
||||
Apply Numbers Застосувати нумерацію
|
||||
Convert to Text Перетворити на текст
|
||||
Filled Dot Заповнена крапка
|
||||
Dash Тире
|
||||
Hollow Circle Порожнє коло
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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"),
|
||||
|
||||
@@ -358,6 +358,15 @@ pub(crate) fn apply_numbers(app: &mut VectorcraftApp, p: &Value) -> Result<Value
|
||||
run_on_paragraphs(app, "text.setFormat", params)
|
||||
}
|
||||
|
||||
/// 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`, which also moves
|
||||
/// the Type tool's anchor and caret past the characters inserted at or before them).
|
||||
pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
text_style(app).ok_or("select some type first")?;
|
||||
run_on_paragraphs(app, "text.convertListToText", json!({}))
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -735,6 +744,7 @@ fn reset_panel(app: &mut VectorcraftApp) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn alignment_row(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) -> egui::Rect {
|
||||
@@ -1172,6 +1182,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.
|
||||
|
||||
+29
-1
@@ -4542,6 +4542,34 @@ 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, `text.convertListToText` ends its typing first and moves its anchor and caret past the characters
|
||||
inserted at or before them, whether the call comes from the menu, MCP or the control channel.
|
||||
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 +4582,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user