mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3960 from dotCooCoo/feat/paragraph-numbered-lists
Type: the Numbered List button numbers paragraphs 1, 2, 3
This commit is contained in:
@@ -177,6 +177,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-para-justify-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-ltr.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-numbers.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: the digits 1, 2 and 3, each before a line | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-rtl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-path-eraser.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3.6 4.2L5 3.2V8.8" stroke-width="1.2" /><path d="M3 10.5C3.3 9.6 6.6 9.4 6.6 10.9C6.6 12.1 3 13.1 3 14.6H6.8" stroke-width="1.2" /><path d="M3 15.6H6.4L4.7 17.6C6.6 17.5 7.1 20.6 4.8 20.6C4 20.6 3.4 20.2 3 19.7" stroke-width="1.2" /><path d="M9 6H21" stroke-width="1.5" /><path d="M9 12H21" stroke-width="1.5" /><path d="M9 18H21" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 551 B |
@@ -909,8 +909,13 @@ impl<'a> Scene<'a> {
|
||||
let Some(&first) = run.first() else { continue };
|
||||
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
|
||||
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
|
||||
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| m.text.as_str());
|
||||
let Some(s) = marker.or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
|
||||
// A right-to-left paragraph's marker is drawn with its tab first and its number's
|
||||
// period at the number's left; the TEXT entity holds the characters in that order.
|
||||
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') {
|
||||
Some(number) if m.tab == m.glyphs.start => format!(".{number}"),
|
||||
_ => m.text.clone(),
|
||||
});
|
||||
let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
|
||||
let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue };
|
||||
let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill };
|
||||
let Some(rgb) = self.paint_rgb(paint) else { continue };
|
||||
|
||||
@@ -492,6 +492,30 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() {
|
||||
assert!((all(&e[1], 10)[0] - bullet_x).abs() < 1e-6, "the bullet where it is drawn: {} and {bullet_x}", all(&e[1], 10)[0]);
|
||||
}
|
||||
|
||||
/// A numbered list paragraph's number and period are a TEXT entity of their own where they are
|
||||
/// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the
|
||||
/// number's left.
|
||||
#[test]
|
||||
fn a_number_is_text_of_its_own_before_its_paragraph() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let numbered = |text: &str, count: u32| {
|
||||
let mut d = text_doc(text, 20.0);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic));
|
||||
t.para.left_indent = 30.0;
|
||||
t.para.first_line_indent = -30.0;
|
||||
t.list_count = count;
|
||||
}
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]);
|
||||
let rtl = numbered("שלום", 11);
|
||||
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
|
||||
assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}");
|
||||
}
|
||||
|
||||
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
|
||||
#[test]
|
||||
fn shrunk_area_type_is_text_at_the_size_it_draws_at() {
|
||||
|
||||
+182
-8
@@ -508,9 +508,102 @@ impl Bullet {
|
||||
}
|
||||
}
|
||||
|
||||
/// The list a paragraph is in (Paragraph panel › Bullets). The layout draws the list's marker
|
||||
/// before the paragraph's first line, in the font, size and color of the paragraph's first
|
||||
/// character without its baseline shift, rotation, superscript or subscript, tracking, manual
|
||||
/// How a numbered list writes its numbers ([`ParaList::number`]): the Paragraph panel's Numbered
|
||||
/// List presets.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NumberStyle {
|
||||
/// 1, 2, 3…
|
||||
#[default]
|
||||
Arabic,
|
||||
/// A, B, C… Z, AA, BB…
|
||||
UpperLetters,
|
||||
/// a, b, c… z, aa, bb…
|
||||
LowerLetters,
|
||||
/// I, II, III, IV…
|
||||
UpperRoman,
|
||||
/// i, ii, iii, iv…
|
||||
LowerRoman,
|
||||
/// 01, 02… 99, 100…
|
||||
TwoDigits,
|
||||
/// 001, 002… 999, 1000…
|
||||
ThreeDigits,
|
||||
}
|
||||
|
||||
/// Past this number, roman numerals and letters are written in Arabic digits.
|
||||
const MAX_LETTERS_OR_ROMAN: u32 = 3999;
|
||||
|
||||
impl NumberStyle {
|
||||
/// Every style, in the order the Numbered List presets list them.
|
||||
pub const ALL: [NumberStyle; 7] = [
|
||||
NumberStyle::Arabic,
|
||||
NumberStyle::UpperLetters,
|
||||
NumberStyle::LowerLetters,
|
||||
NumberStyle::UpperRoman,
|
||||
NumberStyle::LowerRoman,
|
||||
NumberStyle::TwoDigits,
|
||||
NumberStyle::ThreeDigits,
|
||||
];
|
||||
|
||||
/// Number `n` (1 or more) written in this style, without a separator. Letters run A to Z, then
|
||||
/// AA to ZZ, then AAA and so on; roman numerals use the subtractive forms (IV, IX, XL, XC, CD,
|
||||
/// CM); both are written in Arabic digits past 3999. Two and three digits pad with zeros and
|
||||
/// keep every digit of a longer number.
|
||||
pub fn format(self, n: u32) -> String {
|
||||
let letters_or_roman =
|
||||
matches!(self, NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::UpperRoman | NumberStyle::LowerRoman);
|
||||
if n == 0 || (letters_or_roman && n > MAX_LETTERS_OR_ROMAN) {
|
||||
return n.to_string();
|
||||
}
|
||||
match self {
|
||||
NumberStyle::Arabic => n.to_string(),
|
||||
NumberStyle::TwoDigits => format!("{n:02}"),
|
||||
NumberStyle::ThreeDigits => format!("{n:03}"),
|
||||
NumberStyle::UpperLetters => letters(n),
|
||||
NumberStyle::LowerLetters => letters(n).to_lowercase(),
|
||||
NumberStyle::UpperRoman => roman(n),
|
||||
NumberStyle::LowerRoman => roman(n).to_lowercase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` (1 or more) in capital letters: A to Z, then each letter twice, three times and so on.
|
||||
fn letters(n: u32) -> String {
|
||||
let n = n - 1;
|
||||
let letter = char::from(b'A' + (n % 26) as u8);
|
||||
std::iter::repeat_n(letter, (n / 26 + 1) as usize).collect()
|
||||
}
|
||||
|
||||
/// `n` (1 to 3999) in capital roman numerals.
|
||||
fn roman(mut n: u32) -> String {
|
||||
const DIGITS: [(u32, &str); 13] = [
|
||||
(1000, "M"),
|
||||
(900, "CM"),
|
||||
(500, "D"),
|
||||
(400, "CD"),
|
||||
(100, "C"),
|
||||
(90, "XC"),
|
||||
(50, "L"),
|
||||
(40, "XL"),
|
||||
(10, "X"),
|
||||
(9, "IX"),
|
||||
(5, "V"),
|
||||
(4, "IV"),
|
||||
(1, "I"),
|
||||
];
|
||||
let mut s = String::new();
|
||||
for (value, digits) in DIGITS {
|
||||
while n >= value {
|
||||
s.push_str(digits);
|
||||
n -= value;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the
|
||||
/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's
|
||||
/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual
|
||||
/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after
|
||||
/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a
|
||||
/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves
|
||||
@@ -518,19 +611,52 @@ impl Bullet {
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ParaList {
|
||||
/// The bullet each paragraph of the list starts with.
|
||||
/// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for
|
||||
/// when the list is made bulleted again.
|
||||
pub bullet: Bullet,
|
||||
/// A numbered list's number style. A bulleted list has none, and the file saves it only for a
|
||||
/// numbered list.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number: Option<NumberStyle>,
|
||||
}
|
||||
|
||||
impl ParaList {
|
||||
/// A bulleted list with `bullet`.
|
||||
pub fn bulleted(bullet: Bullet) -> Self {
|
||||
Self { bullet }
|
||||
Self { bullet, number: None }
|
||||
}
|
||||
|
||||
/// The characters of the marker the layout draws before the paragraph.
|
||||
pub fn marker(&self) -> String {
|
||||
self.bullet.character().to_string()
|
||||
/// A numbered list with `style`.
|
||||
pub fn numbered(style: NumberStyle) -> Self {
|
||||
Self { bullet: Bullet::default(), number: Some(style) }
|
||||
}
|
||||
|
||||
/// The characters of the marker the layout draws before the paragraph: the bullet, or for a
|
||||
/// numbered list number `n` in its style followed by a period.
|
||||
pub fn marker(&self, n: u32) -> String {
|
||||
match self.number {
|
||||
Some(style) => format!("{}.", style.format(n)),
|
||||
None => self.bullet.character().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and
|
||||
/// which holds no text when `empty`, with `count` the numbered paragraphs counted before it.
|
||||
/// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has
|
||||
/// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the
|
||||
/// count as it is; a numbered paragraph adds one to the count and is numbered with it.
|
||||
pub fn next_marker(list: Option<ParaList>, empty: bool, count: &mut u32) -> Option<String> {
|
||||
let Some(list) = list else {
|
||||
*count = 0;
|
||||
return None;
|
||||
};
|
||||
if empty {
|
||||
return None;
|
||||
}
|
||||
if list.number.is_some() {
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
Some(list.marker(*count))
|
||||
}
|
||||
|
||||
/// The style the marker is set in: `first` (the style of the paragraph's first character)
|
||||
@@ -1536,6 +1662,12 @@ pub struct TextObject {
|
||||
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/turn-fractional-character-widths-off-or-on.html>
|
||||
#[serde(default = "yes", rename = "fractionalWidths", skip_serializing_if = "crate::skip::is_true")]
|
||||
pub fractional_widths: bool,
|
||||
/// Threaded text: the numbered list paragraphs the story's earlier frames count before this
|
||||
/// frame's first paragraph ([`ParaList::next_marker`]), so one count runs through the story.
|
||||
/// The engine sets it whenever it re-flows a thread. Type that is not threaded has 0, and the
|
||||
/// file saves the count only when it is not 0.
|
||||
#[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub list_count: u32,
|
||||
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
|
||||
#[serde(skip)]
|
||||
pub cached_bounds: Option<Rect>,
|
||||
@@ -1635,6 +1767,7 @@ impl TextObject {
|
||||
optical_margin: false,
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
list_count: 0,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
}
|
||||
@@ -2132,6 +2265,47 @@ mod tests {
|
||||
assert_eq!(same, d);
|
||||
}
|
||||
|
||||
/// Numbered lists: each number style writes its numbers and rolls over (letters after Z,
|
||||
/// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds
|
||||
/// a period, and the count runs through list paragraphs, skips empty and bulleted ones and
|
||||
/// starts again after a paragraph in no list. A list from before number styles reads as
|
||||
/// bulleted, and a bulleted list saves no number style.
|
||||
#[test]
|
||||
fn number_styles_write_their_numbers_and_roll_over() {
|
||||
let numbers = |style: NumberStyle, ns: &[u32]| ns.iter().map(|&n| style.format(n)).collect::<Vec<_>>();
|
||||
assert_eq!(numbers(NumberStyle::Arabic, &[1, 9, 10, 123]), ["1", "9", "10", "123"]);
|
||||
assert_eq!(
|
||||
numbers(NumberStyle::UpperLetters, &[1, 2, 26, 27, 28, 52, 53, 3999, 4000]),
|
||||
["A", "B", "Z", "AA", "BB", "ZZ", "AAA", &"U".repeat(154), "4000"]
|
||||
);
|
||||
assert_eq!(numbers(NumberStyle::LowerLetters, &[1, 26, 27]), ["a", "z", "aa"]);
|
||||
assert_eq!(
|
||||
numbers(NumberStyle::UpperRoman, &[1, 2, 3, 4, 5, 9, 14, 40, 90, 400, 1994, 3999, 4000]),
|
||||
["I", "II", "III", "IV", "V", "IX", "XIV", "XL", "XC", "CD", "MCMXCIV", "MMMCMXCIX", "4000"]
|
||||
);
|
||||
assert_eq!(numbers(NumberStyle::LowerRoman, &[1, 4, 9, 12]), ["i", "iv", "ix", "xii"]);
|
||||
assert_eq!(numbers(NumberStyle::TwoDigits, &[1, 9, 10, 99, 100]), ["01", "09", "10", "99", "100"]);
|
||||
assert_eq!(numbers(NumberStyle::ThreeDigits, &[1, 10, 999, 1000]), ["001", "010", "999", "1000"]);
|
||||
assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV.");
|
||||
assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}");
|
||||
let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot)));
|
||||
let mut count = 0;
|
||||
let markers: Vec<Option<String>> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)]
|
||||
.iter()
|
||||
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count))
|
||||
.collect();
|
||||
assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]);
|
||||
assert_eq!(count, 1);
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash));
|
||||
assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits));
|
||||
assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"}));
|
||||
assert_eq!(
|
||||
serde_json::to_value(ParaList::numbered(NumberStyle::LowerRoman)).unwrap(),
|
||||
serde_json::json!({"bullet": "dot", "number": "lowerRoman"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -900,6 +900,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
optical_margin: false,
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
list_count: 0,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
@@ -1148,6 +1149,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
optical_margin: false,
|
||||
system_layout: t.system_layout,
|
||||
fractional_widths: t.fractional_widths,
|
||||
list_count: 0,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -153,11 +153,17 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
next.runs = part.runs;
|
||||
next.set_paragraph_styles(part.paras);
|
||||
let optical = optical.unwrap_or(t.optical_margin);
|
||||
if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin {
|
||||
if next.runs != t.runs
|
||||
|| next.para != t.para
|
||||
|| next.paras != t.paras
|
||||
|| optical != t.optical_margin
|
||||
|| part.list_count != t.list_count
|
||||
{
|
||||
t.runs = next.runs;
|
||||
t.para = next.para;
|
||||
t.paras = next.paras;
|
||||
t.optical_margin = optical;
|
||||
t.list_count = part.list_count;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -169,6 +175,36 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
doc.text_threads = kept;
|
||||
}
|
||||
|
||||
/// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count
|
||||
/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a
|
||||
/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of
|
||||
/// its thread) gets 0 and is laid out again.
|
||||
pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
|
||||
let mut stale = vec![];
|
||||
doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.list_count != 0
|
||||
{
|
||||
stale.push(n.id);
|
||||
}
|
||||
});
|
||||
if stale.is_empty() {
|
||||
return;
|
||||
}
|
||||
let threaded: std::collections::HashSet<NodeId> = doc.text_threads.iter().flatten().copied().filter(|id| text_of(doc, *id).is_some()).collect();
|
||||
stale.retain(|id| !threaded.contains(id));
|
||||
if stale.is_empty() {
|
||||
return;
|
||||
}
|
||||
let doc = std::sync::Arc::make_mut(doc);
|
||||
for id in stale {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.list_count = 0;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
|
||||
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
|
||||
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
|
||||
@@ -478,6 +514,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
vectorcraft_text::edit::normalize(&mut story);
|
||||
t.runs = story;
|
||||
t.set_paragraph_styles(paras);
|
||||
refresh_bounds(t);
|
||||
}
|
||||
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
|
||||
d.text_threads[i] = keep;
|
||||
@@ -1130,4 +1167,178 @@ mod tests {
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One count runs through a thread: the second frame's numbered paragraphs go on from the
|
||||
/// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a
|
||||
/// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks
|
||||
/// and the frame a Release Selection leaves alone count from 1.
|
||||
#[test]
|
||||
fn numbered_list_paragraphs_count_on_through_a_thread() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).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 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 all = |from: usize| (from..=10).map(|n| format!("{n}.")).collect::<Vec<_>>();
|
||||
let first = numbers(&s, a);
|
||||
assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}");
|
||||
assert_eq!([first.clone(), numbers(&s, b)].concat(), all(1));
|
||||
// Typing a paragraph into the first frame numbers it and moves the rest on.
|
||||
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "Item 0\n"})).unwrap();
|
||||
assert_eq!([numbers(&s, a), numbers(&s, b)].concat(), [vec!["1.".to_string()], all(2), vec!["11.".to_string()]].concat());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
|
||||
let after: Vec<String> = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).markers.iter().map(|m| m.text.clone()).collect();
|
||||
assert_eq!(after, all(first.len() + 1), "reopened");
|
||||
// A pasted copy of the second frame is in no thread.
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.objects.first().copied().unwrap();
|
||||
assert_ne!(copy, NodeId(b));
|
||||
assert_eq!(numbers(&s, copy.0).first().map(String::as_str), Some("1."));
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let duplicate = s.execute("edit.duplicate", &json!({"dx": 10})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
assert_eq!(numbers(&s, duplicate).first().map(String::as_str), Some("1."), "a duplicate");
|
||||
let layer = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let moved = s.execute("layer.move", &json!({"ids": [b], "target": layer, "copy": true})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
assert_eq!(numbers(&s, moved).first().map(String::as_str), Some("1."), "a copy dragged in the Layers panel");
|
||||
assert_eq!(numbers(&s, b), all(first.len() + 1), "the frame itself");
|
||||
// Convert To Point Type takes the second frame out of the thread with its text.
|
||||
s.execute("type.convertToPointType", &json!({"ids": [b]})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."), "the frame converted to point type");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(numbers(&s, b), all(first.len() + 1), "undo");
|
||||
// Remove Threading: each frame is a story of its own.
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("text.thread.remove", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(numbers(&s, b), all(first.len() + 1), "undo");
|
||||
// Release Selection of the first frame: the second one holds the whole story, from 1.
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert_eq!(numbers(&s, b), all(1), "the frame left holds the story");
|
||||
}
|
||||
|
||||
/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art
|
||||
/// looks as it did; Break Link puts the frames in no thread, and each counts from 1.
|
||||
#[test]
|
||||
fn a_symbol_made_of_a_numbered_thread_keeps_its_numbers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).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 count = |t: &TextObject| t.list_count;
|
||||
let carried = text_of(&s.doc().unwrap().doc, NodeId(b)).map(count).unwrap();
|
||||
assert!(carried > 0);
|
||||
let instance = new_id(s.execute("symbol.new", &json!({"name": "Numbered"})).unwrap());
|
||||
let counts = |n: &Node| {
|
||||
let mut counts = vec![];
|
||||
n.walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
counts.push(t.list_count);
|
||||
}
|
||||
});
|
||||
counts.sort_unstable();
|
||||
counts
|
||||
};
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(counts(&doc.symbols.iter().find(|x| x.name == "Numbered").unwrap().art), [0, carried]);
|
||||
// Break Link puts the frames on the artboard in no thread: each counts from 1.
|
||||
s.execute("select.set", &json!({"ids": [instance]})).unwrap();
|
||||
s.execute("symbol.breakLink", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::<Vec<_>>(), [0, 0]);
|
||||
}
|
||||
|
||||
/// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the
|
||||
/// mask art too.
|
||||
#[test]
|
||||
fn a_frame_pasted_into_an_edited_opacity_mask_numbers_from_1() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).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!(text_of(&s.doc().unwrap().doc, NodeId(b)).is_some_and(|t| t.list_count > 0));
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let obj = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 280})).unwrap());
|
||||
let m = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [obj, m]})).unwrap();
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let mut counts = vec![];
|
||||
doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
counts.push(t.list_count);
|
||||
}
|
||||
});
|
||||
assert_eq!(counts, [0], "the pasted frame in the mask art");
|
||||
}
|
||||
|
||||
/// A duplicate of a frame that carried a count of 99 or more numbers from 1 and has the layout
|
||||
/// bounds of what it draws: "01." leaves the text at the left indent, where a three-digit
|
||||
/// number pushed it on to the next tab stop and the paragraph took more lines.
|
||||
#[test]
|
||||
fn a_duplicated_frame_numbers_from_1_with_its_own_bounds() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let story = format!("{}Itemized lists paragraph text", "x\n".repeat(110));
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 1500}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 300})).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], "number": "twoDigits"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let copy = s.execute("edit.duplicate", &json!({"dx": 200})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
let laid = |s: &Session, id: u64| {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("area type") };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
(lay.markers[0].text.clone(), lay.lines.len(), lay.bounds, t.cached_bounds.unwrap())
|
||||
};
|
||||
let (number, lines, _, _) = laid(&s, b);
|
||||
let (copy_number, copy_lines, bounds, cached) = laid(&s, copy);
|
||||
assert!(number.len() == 4 && number.starts_with('1'), "a three-digit number: {number}");
|
||||
assert_eq!(copy_number, "01.");
|
||||
assert!(copy_lines < lines, "{copy_lines} {lines}");
|
||||
assert!((cached.y1 - bounds.y1).abs() < 1e-6, "the copy's bounds: {cached:?} {bounds:?}");
|
||||
}
|
||||
|
||||
/// Release Selection that leaves one frame of a thread gives that frame the whole story and
|
||||
/// the layout bounds of its new text.
|
||||
#[test]
|
||||
fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() {
|
||||
let (mut s, a, b) = setup();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert_eq!(text(&s, b), TEXT.repeat(4));
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(b)) else { panic!("area type") };
|
||||
let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds;
|
||||
let cached = t.cached_bounds.unwrap();
|
||||
assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1537,6 +1537,79 @@ mod area_tests {
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
|
||||
}
|
||||
|
||||
/// Numbered lists: `list: "number"` numbers the paragraphs 1, 2, 3 and `number` picks the
|
||||
/// style; a list switched between bullets and numbers keeps its bullet and its indents; an
|
||||
/// unknown value is an error; the list is saved, reopened and carried by paragraph styles.
|
||||
#[test]
|
||||
fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
|
||||
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let list = |s: &Session, i: usize| text(s).para_at(i).list;
|
||||
let numbers = |s: &Session| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number"})).unwrap();
|
||||
let arabic = Some(ParaList::numbered(NumberStyle::Arabic));
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (arabic, arabic, arabic));
|
||||
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
|
||||
assert_eq!(numbers(&s), ["1.", "2.", "3."]);
|
||||
// `number` picks the style, `list: "number"` keeps it, and the count runs on through both.
|
||||
s.execute("text.setFormat", &json!({"number": "upperRoman", "start": 0, "end": 0})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 0})).unwrap();
|
||||
let roman = Some(ParaList::numbered(NumberStyle::UpperRoman));
|
||||
assert_eq!(list(&s, 0), roman);
|
||||
assert_eq!(numbers(&s), ["I.", "2.", "3."]);
|
||||
// A bullet makes it bulleted, a number style numbered again, and `list: "bullet"` brings
|
||||
// back the bullet it had; the indents stay as they are.
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square)));
|
||||
s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap();
|
||||
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) });
|
||||
assert_eq!(list(&s, 0), letters);
|
||||
assert_eq!(numbers(&s), ["a.", "2.", "3."]);
|
||||
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
|
||||
for bad in [json!({"number": "roman"}), json!({"number": 1}), json!({"list": "numbered"}), json!({"number": true})] {
|
||||
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!((list(&s, 0), list(&s, 1)), (letters, arabic), "an unknown value changes nothing");
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "number": "lowerLetters"}));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (letters, arabic, arabic));
|
||||
// Return at the end of a numbered item starts the next number.
|
||||
s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nFour"})).unwrap();
|
||||
assert_eq!(numbers(&s), ["a.", "2.", "3.", "4."]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// "none" turns the list off; the next numbered paragraph starts again at 1.
|
||||
s.execute("text.setFormat", &json!({"number": "none", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(list(&s, 1), None);
|
||||
assert_eq!(numbers(&s), ["a.", "1."]);
|
||||
// A style that holds a numbered list sets it, a style captured from a numbered paragraph
|
||||
// carries its list, and one captured from a paragraph without a list turns it off.
|
||||
s.execute("paraStyle.new", &json!({"name": "Roman", "attrs": {"list": {"number": "upperRoman"}}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Roman", "id": id})).unwrap();
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (roman, roman, roman));
|
||||
assert_eq!(numbers(&s), ["I.", "II.", "III."]);
|
||||
let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Captured", "id": id})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Captured", "id": plain})).unwrap();
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(plain)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!(t.para_at(0).list, roman);
|
||||
s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap();
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -1997,6 +1997,9 @@ impl Session {
|
||||
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Text Wrap: area type follows its wrap objects; then threads re-flow.
|
||||
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
|
||||
// Type in no thread numbers its list paragraphs from 1 (before the mask copies
|
||||
// its editing layer's art).
|
||||
cmd::threads::restart_unthreaded_counts(&mut st.doc);
|
||||
// Opacity-mask editing: the mask follows its art on the editing layer.
|
||||
if st.doc.mask_edit.is_some() {
|
||||
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
|
||||
|
||||
@@ -83,7 +83,7 @@ const HEX_LINE: usize = 60;
|
||||
|
||||
pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)";
|
||||
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines";
|
||||
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted lists) are left out";
|
||||
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted and numbered lists) are left out";
|
||||
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
|
||||
pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)";
|
||||
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
|
||||
|
||||
@@ -1017,19 +1017,22 @@ fn variable_font_settings_are_warned_as_left_out() {
|
||||
assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text");
|
||||
}
|
||||
|
||||
/// A legacy `.ai` paragraph has no list: type with a bulleted list is written as its text, with the
|
||||
/// warning that the bullets are left out.
|
||||
/// A legacy `.ai` paragraph has no list: type with a bulleted or numbered list is written as its
|
||||
/// text, with the warning that the bullets and numbers are left out.
|
||||
#[test]
|
||||
fn bulleted_lists_are_warned_as_left_out() {
|
||||
let mut doc = typed();
|
||||
let id = texts(&doc)[0].0.id;
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Square));
|
||||
t.normalize_paras();
|
||||
fn bulleted_and_numbered_lists_are_warned_as_left_out() {
|
||||
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
|
||||
for list in [ParaList::bulleted(Bullet::Square), ParaList::numbered(NumberStyle::UpperRoman)] {
|
||||
let mut doc = typed();
|
||||
let id = texts(&doc)[0].0.id;
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.list = Some(list);
|
||||
t.normalize_paras();
|
||||
}
|
||||
let out = write(&doc);
|
||||
assert!(out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES && w.contains("bulleted and numbered lists")), "{list:?}: {:?}", out.warnings);
|
||||
assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text");
|
||||
}
|
||||
let out = write(&doc);
|
||||
assert!(out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES && w.contains("bulleted lists")), "{:?}", out.warnings);
|
||||
assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2003,9 +2003,10 @@ impl Writer<'_> {
|
||||
self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look");
|
||||
return self.text_outlines(n, t);
|
||||
}
|
||||
// A `<textPath>` holds the characters of the text, not the bullets drawn before them.
|
||||
// A `<textPath>` holds the characters of the text, not the bullets or numbers drawn
|
||||
// before them.
|
||||
if !lay.markers.is_empty() {
|
||||
self.warn("type on a path with bullets is outlined to keep its look");
|
||||
self.warn("type on a path with bullets or numbers is outlined to keep its look");
|
||||
return self.text_outlines(n, t);
|
||||
}
|
||||
return self.text_on_path(n, t, path, *start);
|
||||
|
||||
@@ -253,7 +253,12 @@ fn canvas_glyph_positions(d: &Document) -> (Vec<Drawn>, Vec<Arc<vectorcraft_text
|
||||
let mut faces: Vec<Arc<vectorcraft_text::FontFace>> = vec![];
|
||||
let mut last = None;
|
||||
for (i, g) in lay.glyphs.iter().enumerate() {
|
||||
let text = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] });
|
||||
let mut text = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] }).to_string();
|
||||
// A list marker's characters each have a glyph of their own in these tests, in order.
|
||||
if let Some(m) = lay.marker_of(i).filter(|m| i != m.tab) {
|
||||
let k = (m.glyphs.start..i).filter(|&j| j != m.tab).count();
|
||||
text = m.text.chars().nth(k).map(String::from).unwrap_or_default();
|
||||
}
|
||||
if text.trim().is_empty() || (g.len > 0 && last == Some(g.byte)) {
|
||||
continue;
|
||||
}
|
||||
@@ -371,6 +376,40 @@ fn bullets_are_written_as_their_characters_where_they_are_drawn() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Numbered lists: live text writes each list paragraph's number and period before the
|
||||
/// paragraph's text, and a viewer draws them where the canvas draws them. Type on a path with
|
||||
/// numbers is written as outlines, with a warning.
|
||||
#[test]
|
||||
fn numbers_are_written_as_their_characters_where_they_are_drawn() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let doc = |t: TextObject| {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
};
|
||||
let mut t = TextObject::point(Point::new(20.0, 20.0), "alpha beta gamma delta epsilon\nzeta\neta", style(12.0));
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 160.0)) };
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::UpperRoman));
|
||||
t.para.left_indent = 18.0;
|
||||
t.para.first_line_indent = -18.0;
|
||||
let d = doc(t);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let at = |s: &str| svg.find(s).unwrap_or_else(|| panic!("{s} in {svg}"));
|
||||
assert!(at(">I.\t<") < at("alpha") && at("alpha") < at(">II.\t<") && at(">II.\t<") < at("zeta") && at("zeta") < at(">III.\t<"), "{svg}");
|
||||
assert!(!svg.contains(">-<"), "{svg}");
|
||||
assert_drawn_in_place(&d);
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((250.0, 50.0));
|
||||
let mut t = TextObject::point(Point::new(20.0, 20.0), "Path", style(12.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic));
|
||||
let out = vectorcraft_svg::export_full(&doc(t), &ExportOptions::default(), None);
|
||||
assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("numbers")), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
/// A Justify All line without word spaces spreads its letters; live text keeps the gaps, with
|
||||
/// tracking and manual kerning too.
|
||||
#[test]
|
||||
|
||||
+37
-18
@@ -114,24 +114,31 @@ impl Ctx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bullets: put the marker of list paragraph `pr` (attributes `para`, right to left when
|
||||
/// `rtl`) before its glyphs `sg` and return how many glyphs went in (none for a paragraph
|
||||
/// without a list or without text). The marker's characters are set in
|
||||
/// Bullets and numbered lists: put marker `text` (the bullet, or the number and its period;
|
||||
/// [`list_markers`]) of list paragraph `pr` (attributes `para`, right to left when `rtl`)
|
||||
/// before its glyphs `sg` and return how many glyphs went in (none for a paragraph without a
|
||||
/// marker). The marker's characters are set in
|
||||
/// [`vectorcraft_doc::text::ParaList::marker_style`], and a tab after them takes the text to
|
||||
/// the next tab stop past them on the first line, the left indent counting as a stop when no
|
||||
/// explicit stop comes before it. In a horizontal right-to-left paragraph, whose indents are
|
||||
/// mirrored ([`line_indents`]), these are measured from the right. The tab keeps that width
|
||||
/// however the line is aligned or justified. Each of these glyphs stands for no character of the text: length 0, at the
|
||||
/// mirrored ([`line_indents`]), these are measured from the right, and a number reads left to
|
||||
/// right with its period at its left. The tab keeps that width however the line is aligned or
|
||||
/// justified. Each of these glyphs stands for no character of the text: length 0, at the
|
||||
/// paragraph's first byte, in its first character's run, at the paragraph's embedding level.
|
||||
fn prepend_marker(&self, pr: &Range<usize>, para: &ParaStyle, rtl: bool, sg: &mut Vec<SGlyph>) -> usize {
|
||||
let Some(list) = para.list.filter(|_| !pr.is_empty()) else { return 0 };
|
||||
let text = list.marker();
|
||||
fn prepend_marker(&self, pr: &Range<usize>, para: &ParaStyle, text: Option<&str>, rtl: bool, sg: &mut Vec<SGlyph>) -> usize {
|
||||
let Some(text) = text.filter(|_| para.list.is_some() && !pr.is_empty()) else { return 0 };
|
||||
let st = vectorcraft_doc::text::ParaList::marker_style(self.style_at(pr.start));
|
||||
let Some(run) = self.runs.iter().position(|(r, _)| r.start <= pr.start && pr.start < r.end) else { return 0 };
|
||||
// The paragraph's direction: the marker starts a right-to-left paragraph at its right.
|
||||
let level = if rtl { Level::rtl() } else { Level::ltr() };
|
||||
let mut marker = Vec::with_capacity(text.len() + 1);
|
||||
shape_range(self.db, &text, 0..text.len(), &[(0..text.len(), &st)], &[None], &self.opts.features, &vec![level; text.len()], &mut marker);
|
||||
shape_range(self.db, text, 0..text.len(), &[(0..text.len(), &st)], &[None], &self.opts.features, &vec![level; text.len()], &mut marker);
|
||||
if rtl {
|
||||
// [`visual_order`] draws a right-to-left marker's glyphs in reverse: the number's
|
||||
// glyphs go in reversed, so that it reads left to right, and the period after them.
|
||||
let number = marker.iter().take_while(|g| text.get(g.byte..).is_some_and(|s| !s.starts_with('.'))).count();
|
||||
marker[..number].reverse();
|
||||
}
|
||||
for g in &mut marker {
|
||||
g.byte = pr.start;
|
||||
g.len = 0;
|
||||
@@ -2016,6 +2023,14 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pe
|
||||
compose(sg, &width, &start_credit, &cands, flex, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, ¶ms(loose)))
|
||||
}
|
||||
|
||||
/// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers
|
||||
/// counted through consecutive list paragraphs from [`TextObject::list_count`], as
|
||||
/// [`vectorcraft_doc::text::ParaList::next_marker`] counts them.
|
||||
fn list_markers(t: &TextObject, paras: &[Range<usize>]) -> Vec<Option<String>> {
|
||||
let mut count = t.list_count;
|
||||
paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut count)).collect()
|
||||
}
|
||||
|
||||
/// Whether paragraph `para` (right to left when `rtl`) has its indents mirrored: a horizontal
|
||||
/// right-to-left list paragraph does.
|
||||
fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool {
|
||||
@@ -2046,8 +2061,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
next_top: None,
|
||||
no_room_for: f64::INFINITY,
|
||||
};
|
||||
let markers = list_markers(t, paras);
|
||||
'paras: for (pi, pr) in paras.iter().enumerate() {
|
||||
let para = t.para_at(pi);
|
||||
let marker_text = markers.get(pi).and_then(Option::as_deref);
|
||||
// The leading model is a paragraph attribute: this paragraph's lines (and the space to
|
||||
// its first line) follow its own.
|
||||
pen.model = para.leading_model;
|
||||
@@ -2064,9 +2081,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
// Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how
|
||||
// far a justified line may move each one from it.
|
||||
let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical);
|
||||
// Bullets: the list marker's glyphs `0..marker` start the paragraph. Justification and
|
||||
// Optical Margin Alignment leave them as they are.
|
||||
let marker = cx.prepend_marker(pr, para, rtl, &mut sg);
|
||||
// Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph.
|
||||
// Justification and Optical Margin Alignment leave them as they are.
|
||||
let marker = cx.prepend_marker(pr, para, marker_text, rtl, &mut sg);
|
||||
if marker > 0 {
|
||||
flex.splice(0..0, vec![Flex::default(); marker]);
|
||||
if !compressed.is_empty() {
|
||||
@@ -2452,8 +2469,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
x_end = x;
|
||||
}
|
||||
}
|
||||
if let (Some(glyphs), Some(list)) = (marker_glyphs, para.list) {
|
||||
cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: list.marker() });
|
||||
if let (Some(glyphs), Some(text)) = (marker_glyphs, marker_text) {
|
||||
cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: text.to_string() });
|
||||
}
|
||||
if let Some(h) = &hyphen {
|
||||
// The hyphen follows the last non-space glyph.
|
||||
@@ -2520,13 +2537,15 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
|
||||
let mut rtl = None;
|
||||
let mut shaped = Vec::new();
|
||||
let markers = list_markers(t, paras);
|
||||
for (pi, pr) in paras.iter().enumerate() {
|
||||
let text = cx.text;
|
||||
let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr));
|
||||
rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical);
|
||||
let mut glyphs = cx.shape_para(pr.clone(), bidi.as_ref());
|
||||
// Bullets: a list paragraph's text starts after its marker here too.
|
||||
cx.prepend_marker(pr, t.para_at(pi), is_rtl(bidi.as_ref()) && !cx.vertical, &mut glyphs);
|
||||
// Bullets and numbers: a list paragraph's text starts after its marker here too.
|
||||
let marker_text = markers.get(pi).and_then(Option::as_deref);
|
||||
cx.prepend_marker(pr, t.para_at(pi), marker_text, is_rtl(bidi.as_ref()) && !cx.vertical, &mut glyphs);
|
||||
shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs));
|
||||
}
|
||||
let rtl = rtl.unwrap_or(false);
|
||||
@@ -2742,8 +2761,8 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
match cx.out.markers.last_mut() {
|
||||
Some(m) if m.glyphs.end == at && cx.out.glyphs.get(m.glyphs.start).is_some_and(|h| h.byte == g.byte) => m.glyphs.end = at + 1,
|
||||
_ => {
|
||||
if let Some(list) = starts_paragraph(g.byte).and_then(|pi| t.para_at(pi).list) {
|
||||
cx.out.markers.push(ListMarker { glyphs: at..at + 1, tab: at, text: list.marker() });
|
||||
if let Some(text) = starts_paragraph(g.byte).and_then(|pi| markers.get(pi)).and_then(Option::as_ref) {
|
||||
cx.out.markers.push(ListMarker { glyphs: at..at + 1, tab: at, text: text.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,6 +970,103 @@ fn a_list_paragraph_starts_its_text_at_the_next_tab_stop_after_the_bullet() {
|
||||
assert!(first.iter().any(|g| g.len > 0), "the first line holds more than the bullet: {:?}", lay.lines);
|
||||
}
|
||||
|
||||
/// The list markers of `t`'s layout, in text order.
|
||||
fn marker_texts(t: &TextObject) -> Vec<String> {
|
||||
layout(db(), t).markers.iter().map(|m| m.text.clone()).collect()
|
||||
}
|
||||
|
||||
/// Area type `text` whose paragraphs have the lists `lists` and an 18 pt hanging indent.
|
||||
fn listed(text: &str, lists: &[Option<vectorcraft_doc::text::ParaList>]) -> TextObject {
|
||||
let mut t = area(text, style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), Justify::Left);
|
||||
t.para.left_indent = 18.0;
|
||||
t.para.first_line_indent = -18.0;
|
||||
let styles = lists.iter().map(|&list| vectorcraft_doc::ParaStyle { list, ..t.para.clone() }).collect();
|
||||
t.set_paragraph_styles(styles);
|
||||
t
|
||||
}
|
||||
|
||||
/// Numbered lists: each list paragraph starts with its number and a period, counted through the
|
||||
/// list paragraphs before it, and its text starts at the left indent after the number. An empty
|
||||
/// paragraph has no number and is not counted, a bulleted paragraph keeps its bullet and is not
|
||||
/// counted, and a paragraph in no list starts the count again. Each number style writes its
|
||||
/// numbers and rolls over, and a number wider than the indent takes the text to the next tab stop.
|
||||
#[test]
|
||||
fn numbered_list_paragraphs_are_numbered_in_order() {
|
||||
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
|
||||
let numbered = Some(ParaList::numbered(NumberStyle::Arabic));
|
||||
let t = listed("One\nTwo\nThree", &[numbered; 3]);
|
||||
assert_eq!(marker_texts(&t), ["1.", "2.", "3."]);
|
||||
let lay = layout(db(), &t);
|
||||
for (k, m) in lay.markers.iter().enumerate() {
|
||||
let number = &lay.glyphs[m.glyphs.start];
|
||||
let line = &lay.lines[number.line];
|
||||
let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap();
|
||||
assert!(number.origin.x.abs() < 1e-6 && (text.origin.x - 18.0).abs() < 1e-6, "item {k}: {} {}", number.origin.x, text.origin.x);
|
||||
assert_eq!((m.tab, lay.marker_text(m.tab)), (m.glyphs.end - 1, Some("\t")), "item {k}");
|
||||
}
|
||||
assert_eq!(marker_texts(&listed("One\n\nTwo", &[numbered; 3])), ["1.", "2."]);
|
||||
assert_eq!(marker_texts(&listed("A\nB\nC\nD\nE", &[numbered, numbered, None, numbered, numbered])), ["1.", "2.", "1.", "2."]);
|
||||
assert_eq!(marker_texts(&listed("A\nB\nC", &[numbered, Some(ParaList::bulleted(Bullet::Dash)), numbered])), ["1.", "\u{2013}", "2."]);
|
||||
// The count goes on from the earlier frames of a thread.
|
||||
let mut t = listed("One\nTwo", &[numbered; 2]);
|
||||
t.list_count = 9;
|
||||
assert_eq!(marker_texts(&t), ["10.", "11."]);
|
||||
// Each style, from the count before its rollover.
|
||||
for (style, before, numbers) in [
|
||||
(NumberStyle::Arabic, 0, ["1.", "2."]),
|
||||
(NumberStyle::UpperLetters, 25, ["Z.", "AA."]),
|
||||
(NumberStyle::LowerLetters, 25, ["z.", "aa."]),
|
||||
(NumberStyle::UpperRoman, 3, ["IV.", "V."]),
|
||||
(NumberStyle::LowerRoman, 8, ["ix.", "x."]),
|
||||
(NumberStyle::TwoDigits, 98, ["99.", "100."]),
|
||||
(NumberStyle::ThreeDigits, 998, ["999.", "1000."]),
|
||||
] {
|
||||
let mut t = listed("One\nTwo", &[Some(ParaList::numbered(style)); 2]);
|
||||
t.list_count = before;
|
||||
assert_eq!(marker_texts(&t), numbers, "{style:?}");
|
||||
}
|
||||
// "1000." is wider than the 18 pt before the left indent: the text goes to the next default stop.
|
||||
let mut wide = listed("One", &[Some(ParaList::numbered(NumberStyle::ThreeDigits))]);
|
||||
wide.list_count = 999;
|
||||
let lay = layout(db(), &wide);
|
||||
let number_width: f64 = lay.glyphs[..lay.markers[0].tab].iter().map(|g| g.advance).sum();
|
||||
let text = lay.glyphs.iter().find(|g| g.len > 0).unwrap();
|
||||
assert!(number_width > 18.0 && (text.origin.x - 36.0).abs() < 1e-6, "{number_width} {}", text.origin.x);
|
||||
// Type on a path numbers its paragraphs too.
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((500.0, 50.0));
|
||||
let mut on_path = listed("One\nTwo", &[numbered; 2]);
|
||||
on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
assert_eq!(marker_texts(&on_path), ["1.", "2."]);
|
||||
}
|
||||
|
||||
/// Numbered lists: a right-to-left list paragraph's number ends its first line at the right and
|
||||
/// reads left to right, with its period at its left and the gap before the text left of that.
|
||||
#[test]
|
||||
fn a_right_to_left_numbered_paragraph_has_its_period_left_of_its_number() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let numbered = Some(ParaList::numbered(NumberStyle::Arabic));
|
||||
// The glyphs of "1", "2" and "." in a left-to-right "12.".
|
||||
let mut ltr = listed("One", &[numbered]);
|
||||
ltr.list_count = 11;
|
||||
let lay = layout(db(), <r);
|
||||
let gids: Vec<u32> = lay.glyphs[..lay.markers[0].tab].iter().map(|g| g.gid).collect();
|
||||
assert_eq!(gids.len(), 3, "{gids:?}");
|
||||
let mut rtl = listed("שלום עולם", &[numbered]);
|
||||
rtl.edit_paras(None, |p| p.justify = Justify::Auto);
|
||||
rtl.list_count = 11;
|
||||
let lay = layout(db(), &rtl);
|
||||
let m = &lay.markers[0];
|
||||
assert_eq!(m.text, "12.");
|
||||
assert_eq!(m.tab, m.glyphs.start, "the gap is drawn first, left of the number");
|
||||
let drawn = &lay.glyphs[m.glyphs.start + 1..m.glyphs.end];
|
||||
assert_eq!(drawn.iter().map(|g| g.gid).collect::<Vec<_>>(), [gids[2], gids[0], gids[1]], "drawn left to right: the period, 1, 2");
|
||||
assert!(drawn.windows(2).all(|w| w[0].origin.x < w[1].origin.x), "{drawn:?}");
|
||||
let last = drawn.last().unwrap();
|
||||
assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6, "the number ends the line at the right: {last:?}");
|
||||
}
|
||||
|
||||
/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when
|
||||
/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts.
|
||||
fn japanese_fonts() -> bool {
|
||||
|
||||
@@ -71,21 +71,39 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
|
||||
end
|
||||
}
|
||||
|
||||
/// A frame's share of a story: its runs and its paragraphs' styles (one per paragraph).
|
||||
/// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the
|
||||
/// numbered list paragraphs counted before its first one ([`TextObject::list_count`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Slice {
|
||||
pub runs: Vec<TextRun>,
|
||||
pub paras: Vec<ParaStyle>,
|
||||
pub list_count: u32,
|
||||
}
|
||||
|
||||
/// The count of numbered list paragraphs after the paragraphs of `runs` (styles `paras`, one per
|
||||
/// paragraph) but the last one, counted on from `count` as the layout counts them
|
||||
/// ([`vectorcraft_doc::text::ParaList::next_marker`]). A slice's last paragraph also starts the
|
||||
/// next frame, which counts it.
|
||||
fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut count: u32) -> u32 {
|
||||
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
|
||||
let mut pieces: Vec<&str> = text.split('\n').collect();
|
||||
pieces.pop();
|
||||
for (piece, para) in pieces.iter().zip(paras) {
|
||||
vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut count);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// The story `runs` (with paragraph styles `paras`, one per story paragraph; empty = each
|
||||
/// frame's own) split across `frames` in order (the last frame takes any overflow). Empty slices
|
||||
/// keep one empty run carrying the style at the split, so typing there has a style. A paragraph
|
||||
/// split between frames has its style in both.
|
||||
/// split between frames has its style in both. Each frame's numbered list paragraphs are counted
|
||||
/// on from the frames before it.
|
||||
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec<Slice> {
|
||||
let mut rest = runs.to_vec();
|
||||
let mut out = Vec::with_capacity(frames.len());
|
||||
let mut next_para = 0;
|
||||
let mut list_count = 0;
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let first_para = next_para;
|
||||
let len = runs_len(&rest);
|
||||
@@ -97,6 +115,7 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras:
|
||||
let mut probe = (*f).clone();
|
||||
probe.runs = rest.clone();
|
||||
probe.set_paragraph_styles(slice_paras(paras, first_para, &rest));
|
||||
probe.list_count = list_count;
|
||||
fit(db, &probe, &rest)
|
||||
};
|
||||
let mut head = slice_runs(&rest, 0, n);
|
||||
@@ -107,7 +126,9 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras:
|
||||
rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail };
|
||||
let head_paras = slice_paras(paras, first_para, &head);
|
||||
next_para += head_paras.len().saturating_sub(1);
|
||||
out.push(Slice { runs: head, paras: head_paras });
|
||||
let next_count = count_list_paragraphs(&head, &head_paras, list_count);
|
||||
out.push(Slice { runs: head, paras: head_paras, list_count });
|
||||
list_count = next_count;
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -260,6 +281,44 @@ mod tests {
|
||||
assert_eq!(story_paras(&[&fa, &fb], &[false, false])[0].justify, Left);
|
||||
}
|
||||
|
||||
/// Numbered list paragraphs count on from one frame to the next: the second frame's first
|
||||
/// whole paragraph takes the next number. A paragraph split between the frames is numbered
|
||||
/// alike in both, and the paragraph after it takes the next number.
|
||||
#[test]
|
||||
fn numbered_list_paragraphs_count_on_through_the_frames() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let db = FontDb::global();
|
||||
let (a, mut b) = (frame(0.0), frame(200.0));
|
||||
b.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(200.0, 0.0, 320.0, 400.0)) };
|
||||
let numbered =
|
||||
ParaStyle { list: Some(ParaList::numbered(NumberStyle::Arabic)), left_indent: 18.0, first_line_indent: -18.0, ..Default::default() };
|
||||
let numbers = |f: &TextObject, s: &Slice| {
|
||||
let mut t = f.clone();
|
||||
t.runs = s.runs.clone();
|
||||
t.set_paragraph_styles(s.paras.clone());
|
||||
t.list_count = s.list_count;
|
||||
layout(db, &t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
// Whole paragraphs: the first frame ends with a paragraph break.
|
||||
let text = (1..=8).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let story = vec![TextRun { text, style: CharStyle::default(), inline: None }];
|
||||
let parts = distribute(db, &[&a, &b], &story, &vec![numbered.clone(); 8]);
|
||||
let first = numbers(&a, &parts[0]);
|
||||
assert!((2..8).contains(&first.len()), "both frames hold items: {first:?}");
|
||||
assert_eq!(parts.iter().map(|s| s.list_count).collect::<Vec<_>>(), [0, first.len() as u32]);
|
||||
let second = numbers(&b, &parts[1]);
|
||||
let expected: Vec<String> = (first.len() + 1..=8).map(|n| format!("{n}.")).collect();
|
||||
assert_eq!(second, expected, "{first:?}");
|
||||
// A paragraph split between the frames.
|
||||
let text = format!("One\nTwo\n{}\nFour", "The quick brown fox jumps over the lazy dog. ".repeat(4));
|
||||
let story = vec![TextRun { text, style: CharStyle::default(), inline: None }];
|
||||
let parts = distribute(db, &[&a, &b], &story, &vec![numbered; 4]);
|
||||
assert_eq!(
|
||||
(numbers(&a, &parts[0]), numbers(&b, &parts[1])),
|
||||
(vec!["1.".to_string(), "2.".into(), "3.".into()], vec!["3.".to_string(), "4.".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_story_leaves_later_frames_empty_with_a_style() {
|
||||
let db = FontDb::global();
|
||||
|
||||
@@ -342,6 +342,8 @@ font-technology Variable Променливи
|
||||
Bullets Булети
|
||||
Bullets and Numbering Булети и номериране
|
||||
Apply Bullets Прилагане на булети
|
||||
Numbered List Номериран списък
|
||||
Apply Numbers Прилагане на номериране
|
||||
Filled Dot Плътна точка
|
||||
Dash Тире
|
||||
Hollow Circle Празен кръг
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -325,6 +325,8 @@
|
||||
Bullets Odrážky
|
||||
Bullets and Numbering Odrážky a číslování
|
||||
Apply Bullets Použít odrážky
|
||||
Numbered List Číslovaný seznam
|
||||
Apply Numbers Použít číslování
|
||||
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.
|
@@ -287,6 +287,8 @@
|
||||
Bullets Kugletegn
|
||||
Bullets and Numbering Kugletegn og nummerering
|
||||
Apply Bullets Anvend kugletegn
|
||||
Numbered List Nummereret liste
|
||||
Apply Numbers Anvend nummerering
|
||||
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.
|
@@ -330,6 +330,8 @@ font-technology Variable Variabel
|
||||
Bullets Aufzählungszeichen
|
||||
Bullets and Numbering Aufzählungszeichen und Nummerierung
|
||||
Apply Bullets Aufzählungszeichen anwenden
|
||||
Numbered List Nummerierte Liste
|
||||
Apply Numbers Nummerierung anwenden
|
||||
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.
|
@@ -330,6 +330,8 @@ font-technology Variable Μεταβλητές
|
||||
Bullets Κουκκίδες
|
||||
Bullets and Numbering Κουκκίδες και αρίθμηση
|
||||
Apply Bullets Εφαρμογή κουκκίδων
|
||||
Numbered List Αριθμημένη λίστα
|
||||
Apply Numbers Εφαρμογή αρίθμησης
|
||||
Filled Dot Γεμάτη κουκκίδα
|
||||
Dash Παύλα
|
||||
Hollow Circle Κενός κύκλος
|
||||
|
||||
|
@@ -355,6 +355,8 @@ font-technology Variable Variables
|
||||
Bullets Viñetas
|
||||
Bullets and Numbering Viñetas y numeración
|
||||
Apply Bullets Aplicar viñetas
|
||||
Numbered List Lista numerada
|
||||
Apply Numbers Aplicar numeración
|
||||
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.
|
@@ -322,6 +322,8 @@
|
||||
Bullets Luettelomerkit
|
||||
Bullets and Numbering Luettelomerkit ja numerointi
|
||||
Apply Bullets Käytä luettelomerkkejä
|
||||
Numbered List Numeroitu luettelo
|
||||
Apply Numbers Käytä numerointia
|
||||
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.
|
@@ -356,6 +356,8 @@ font-technology Variable Variables
|
||||
Bullets Puces
|
||||
Bullets and Numbering Puces et numérotation
|
||||
Apply Bullets Appliquer les puces
|
||||
Numbered List Liste numérotée
|
||||
Apply Numbers Appliquer la numérotation
|
||||
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.
|
@@ -491,6 +491,8 @@ font-technology Variable Változó
|
||||
Bullets Felsorolásjelek
|
||||
Bullets and Numbering Felsorolásjelek és számozás
|
||||
Apply Bullets Felsorolásjelek alkalmazása
|
||||
Numbered List Számozott lista
|
||||
Apply Numbers Számozás alkalmazása
|
||||
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.
|
@@ -287,6 +287,8 @@
|
||||
Bullets Poin
|
||||
Bullets and Numbering Poin dan penomoran
|
||||
Apply Bullets Terapkan poin
|
||||
Numbered List Daftar bernomor
|
||||
Apply Numbers Terapkan penomoran
|
||||
Filled Dot Titik penuh
|
||||
Dash Tanda pisah
|
||||
Hollow Circle Lingkaran kosong
|
||||
|
||||
|
@@ -355,6 +355,8 @@ font-technology Variable Variabili
|
||||
Bullets Punti elenco
|
||||
Bullets and Numbering Punti elenco e numerazione
|
||||
Apply Bullets Applica punti elenco
|
||||
Numbered List Elenco numerato
|
||||
Apply Numbers Applica numerazione
|
||||
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.
|
@@ -315,6 +315,8 @@ font-technology Variable バリアブル
|
||||
Bullets 箇条書き
|
||||
Bullets and Numbering 箇条書きと番号付け
|
||||
Apply Bullets 箇条書きを適用
|
||||
Numbered List 番号付きリスト
|
||||
Apply Numbers 番号付けを適用
|
||||
Filled Dot 黒丸
|
||||
Dash ダッシュ
|
||||
Hollow Circle 白丸
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -246,6 +246,8 @@
|
||||
Bullets ბურთულები
|
||||
Bullets and Numbering ბურთულები და დანომრვა
|
||||
Apply Bullets ბურთულების გამოყენება
|
||||
Numbered List დანომრილი სია
|
||||
Apply Numbers დანომრვის გამოყენება
|
||||
Filled Dot სავსე წერტილი
|
||||
Dash ტირე
|
||||
Hollow Circle ცარიელი წრე
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -311,6 +311,8 @@ font-technology Variable 가변
|
||||
Bullets 글머리 기호
|
||||
Bullets and Numbering 글머리 기호 및 번호 매기기
|
||||
Apply Bullets 글머리 기호 적용
|
||||
Numbered List 번호 매기기 목록
|
||||
Apply Numbers 번호 매기기 적용
|
||||
Filled Dot 채운 점
|
||||
Dash 대시
|
||||
Hollow Circle 빈 원
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -301,6 +301,8 @@
|
||||
Bullets Opsommingstekens
|
||||
Bullets and Numbering Opsommingstekens en nummering
|
||||
Apply Bullets Opsommingstekens toepassen
|
||||
Numbered List Genummerde lijst
|
||||
Apply Numbers Nummering toepassen
|
||||
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.
|
@@ -286,6 +286,8 @@
|
||||
Bullets Punktory
|
||||
Bullets and Numbering Punktory i numeracja
|
||||
Apply Bullets Zastosuj punktory
|
||||
Numbered List Lista numerowana
|
||||
Apply Numbers Zastosuj numerację
|
||||
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.
|
@@ -325,6 +325,8 @@
|
||||
Bullets Marcadores
|
||||
Bullets and Numbering Marcadores e Numeração
|
||||
Apply Bullets Aplicar Marcadores
|
||||
Numbered List Lista Numerada
|
||||
Apply Numbers Aplicar Numeração
|
||||
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.
|
@@ -353,6 +353,8 @@ font-technology Variable Вариативные
|
||||
Bullets Маркеры
|
||||
Bullets and Numbering Маркеры и нумерация
|
||||
Apply Bullets Применить маркеры
|
||||
Numbered List Нумерованный список
|
||||
Apply Numbers Применить нумерацию
|
||||
Filled Dot Закрашенная точка
|
||||
Dash Тире
|
||||
Hollow Circle Пустой круг
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -344,6 +344,8 @@ font-technology Variable Variabla
|
||||
Bullets Punkter
|
||||
Bullets and Numbering Punkter och numrering
|
||||
Apply Bullets Använd punkter
|
||||
Numbered List Numrerad lista
|
||||
Apply Numbers Använd numrering
|
||||
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.
|
@@ -339,6 +339,8 @@ font-technology Variable Değişken
|
||||
Bullets Madde İşaretleri
|
||||
Bullets and Numbering Madde İşaretleri ve Numaralandırma
|
||||
Apply Bullets Madde İşaretlerini Uygula
|
||||
Numbered List Numaralı Liste
|
||||
Apply Numbers Numaralandırmayı Uygula
|
||||
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.
|
@@ -349,6 +349,8 @@ font-technology Variable Варіативні
|
||||
Bullets Маркери
|
||||
Bullets and Numbering Маркери та нумерація
|
||||
Apply Bullets Застосувати маркери
|
||||
Numbered List Нумерований список
|
||||
Apply Numbers Застосувати нумерацію
|
||||
Filled Dot Заповнена крапка
|
||||
Dash Тире
|
||||
Hollow Circle Порожнє коло
|
||||
|
||||
|
@@ -409,6 +409,8 @@ font-technology Variable Biến thiên
|
||||
Bullets Dấu đầu dòng
|
||||
Bullets and Numbering Dấu đầu dòng và đánh số
|
||||
Apply Bullets Áp dụng dấu đầu dòng
|
||||
Numbered List Danh sách đánh số
|
||||
Apply Numbers Áp dụng đánh số
|
||||
Filled Dot Chấm đặc
|
||||
Dash Gạch ngang
|
||||
Hollow Circle Hình tròn rỗng
|
||||
|
||||
|
@@ -304,6 +304,8 @@
|
||||
Bullets 项目符号
|
||||
Bullets and Numbering 项目符号和编号
|
||||
Apply Bullets 应用项目符号
|
||||
Numbered List 编号列表
|
||||
Apply Numbers 应用编号
|
||||
Filled Dot 实心圆点
|
||||
Dash 短划线
|
||||
Hollow Circle 空心圆
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -317,6 +317,8 @@ font-technology Variable 可變
|
||||
Bullets 項目符號
|
||||
Bullets and Numbering 項目符號與編號
|
||||
Apply Bullets 套用項目符號
|
||||
Numbered List 編號清單
|
||||
Apply Numbers 套用編號
|
||||
Filled Dot 實心圓點
|
||||
Dash 短破折號
|
||||
Hollow Circle 空心圓
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -125,6 +125,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("dc-para-justify-right", include_bytes!("../../../assets/icons/dc-para-justify-right.svg")),
|
||||
("dc-para-left", include_bytes!("../../../assets/icons/dc-para-left.svg")),
|
||||
("dc-para-ltr", include_bytes!("../../../assets/icons/dc-para-ltr.svg")),
|
||||
("dc-para-numbers", include_bytes!("../../../assets/icons/dc-para-numbers.svg")),
|
||||
("dc-para-right", include_bytes!("../../../assets/icons/dc-para-right.svg")),
|
||||
("dc-para-rtl", include_bytes!("../../../assets/icons/dc-para-rtl.svg")),
|
||||
("dc-path-eraser", include_bytes!("../../../assets/icons/dc-path-eraser.svg")),
|
||||
|
||||
@@ -230,6 +230,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{bullet?: \"dot\"|\"dash\"|\"circle\"|\"square\"|\"none\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \"bullet\"}); \"none\" turns the list off",
|
||||
),
|
||||
(
|
||||
"type.applyNumbers",
|
||||
"Apply Numbers",
|
||||
"",
|
||||
"{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",
|
||||
),
|
||||
(
|
||||
"align.horizontalLeft",
|
||||
"Horizontal Align Left",
|
||||
@@ -1416,6 +1422,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
crate::panels::paragraph::align(app, justify)
|
||||
}
|
||||
"type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p),
|
||||
"type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p),
|
||||
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),
|
||||
@@ -2504,7 +2511,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" => crate::panels::character::text_style(app).is_some(),
|
||||
"type.applyBullets" | "type.applyNumbers" => 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()),
|
||||
@@ -3001,7 +3008,7 @@ 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")]),
|
||||
sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")]),
|
||||
Sep,
|
||||
c("Create Outlines", "type.createOutlines"),
|
||||
c("Scale Text Attributes", "type.scaleTextAttributes"),
|
||||
@@ -3382,7 +3389,7 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
|
||||
}
|
||||
// The paragraphs of the selected type, or those the Type tool's selection touches.
|
||||
if crate::panels::character::text_style(app).is_some() {
|
||||
v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets")]));
|
||||
v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")]));
|
||||
}
|
||||
v.extend([
|
||||
c("Make Guides", "view.guides.make"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), the
|
||||
//! Bullets button and its presets, indents, space before/after, Hyphenate, Insert Kashidas (with
|
||||
//! the Indic options), Mojikumi Set and Kinsoku Set (with the East Asian options); the panel menu
|
||||
//! turns Roman Hanging Punctuation on and off, opens Justification… and Hyphenation… and picks the
|
||||
//! Single-line or Every-line Composer.
|
||||
//! Bullets and Numbered List buttons and their presets, indents, space before/after, Hyphenate,
|
||||
//! Insert Kashidas (with the Indic options), Mojikumi Set and Kinsoku Set (with the East Asian
|
||||
//! options); the panel menu turns Roman Hanging Punctuation on and off, opens Justification… and
|
||||
//! Hyphenation… and picks the Single-line or Every-line Composer.
|
||||
|
||||
use egui::{Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -345,6 +345,19 @@ pub(crate) fn apply_bullets(app: &mut VectorcraftApp, p: &Value) -> Result<Value
|
||||
run_on_paragraphs(app, "text.setFormat", params)
|
||||
}
|
||||
|
||||
/// Type › Bullets and Numbering › Apply Numbers (`type.applyNumbers {number?}`): the paragraphs of
|
||||
/// the selected type, or those the Type tool's selection touches, become a numbered list with
|
||||
/// number style `number`; without it, a numbered list keeps its style and other paragraphs are
|
||||
/// numbered 1, 2, 3.
|
||||
pub(crate) fn apply_numbers(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
text_style(app).ok_or("select some type first")?;
|
||||
let params = match p.get("number") {
|
||||
Some(n) => json!({ "number": n }),
|
||||
None => json!({ "list": "number" }),
|
||||
};
|
||||
run_on_paragraphs(app, "text.setFormat", params)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -352,7 +365,7 @@ pub(crate) fn apply_bullets(app: &mut VectorcraftApp, p: &Value) -> Result<Value
|
||||
pub fn bullets_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
use vectorcraft_doc::text::Bullet;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let shown = shared_para(app, |p| p.list.map(|l| l.bullet));
|
||||
let shown = shared_para(app, |p| p.list.filter(|l| l.number.is_none()).map(|l| l.bullet));
|
||||
if widgets::icon_button(ui, "dc-para-bullets", tl!("Bullets"), matches!(shown, Some(Some(_))), size).clicked() {
|
||||
format(app, json!({"list": "bullet"}));
|
||||
}
|
||||
@@ -412,6 +425,60 @@ fn bullet_row(ui: &mut Ui, name: &str, bullet: Option<vectorcraft_doc::text::Bul
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// The Numbered List button and the arrow beside it (Paragraph panel). The button makes the
|
||||
/// paragraphs the panel acts on a numbered list (`text.setFormat {list: "number"}`) and shows
|
||||
/// pressed while they all are one. The arrow lists the presets (`{number}`), each shown by its
|
||||
/// first three numbers: None turns the list off, and the one every paragraph has is checked.
|
||||
pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
use vectorcraft_doc::text::NumberStyle;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let shown = shared_para(app, |p| p.list.and_then(|l| l.number));
|
||||
if widgets::icon_button(ui, "dc-para-numbers", tl!("Numbered List"), matches!(shown, Some(Some(_))), size).clicked() {
|
||||
format(app, json!({"list": "number"}));
|
||||
}
|
||||
let (rect, arrow) = ui.allocate_exact_size(vec2(14.0, size), egui::Sense::click());
|
||||
if arrow.hovered() {
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover);
|
||||
}
|
||||
crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim);
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Numbered List")));
|
||||
let arrow = arrow.on_hover_text(tl!("Numbered List"));
|
||||
let picked = egui::Popup::menu(&arrow).show(|ui| {
|
||||
let mut picked = None;
|
||||
if bullet_row(ui, tl!("None"), None, shown == Some(None)) {
|
||||
picked = Some(None);
|
||||
}
|
||||
for style in NumberStyle::ALL {
|
||||
let sample = (1..=3).map(|n| style.format(n)).collect::<Vec<_>>().join(", ");
|
||||
if number_row(ui, &sample, shown == Some(Some(style))) {
|
||||
picked = Some(Some(style));
|
||||
}
|
||||
}
|
||||
picked
|
||||
});
|
||||
if let Some(style) = picked.and_then(|r| r.inner) {
|
||||
format(app, json!({ "number": style.map_or(json!("none"), |s| json!(s)) }));
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the Numbered List presets: a check when `checked`, then the style's first numbers
|
||||
/// (`sample`). Whether it was clicked.
|
||||
fn number_row(ui: &mut Ui, sample: &str, checked: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let painter = ui.painter();
|
||||
let font = egui::FontId::proportional(12.5);
|
||||
if checked {
|
||||
painter.text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, "✓", font.clone(), t.text_strong);
|
||||
}
|
||||
painter.text(rect.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, sample, font, t.text_strong);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), checked, sample));
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// The Hyphenate checkbox for the paragraphs the panel acts on.
|
||||
pub fn hyphenate_check(app: &mut VectorcraftApp, ui: &mut Ui, _para: &ParaStyle) {
|
||||
let hyphenate = shared_hyphenate(app);
|
||||
@@ -446,6 +513,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
bullets_button(app, ui, 28.0);
|
||||
ui.add_space(6.0);
|
||||
numbers_button(app, ui, 28.0);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let fw = ((ui.available_width() - 66.0) / 2.0).clamp(60.0, 100.0);
|
||||
@@ -995,6 +1064,80 @@ mod tests {
|
||||
assert_eq!(lists(&app), (None, None));
|
||||
}
|
||||
|
||||
/// The Numbered List button makes the paragraphs the Type tool's selection touches a numbered
|
||||
/// list 1, 2, 3 and shows pressed; the arrow's presets, each shown by its first three numbers,
|
||||
/// pick another style, None turns the list off, Type › Bullets and Numbering › Apply Numbers
|
||||
/// applies a numbered list, and Reset Panel turns it off.
|
||||
#[test]
|
||||
fn the_numbered_list_button_and_its_presets_set_the_list_and_reset_panel_clears_it() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let (mut app, id) = editing_text("One\nTwo");
|
||||
select_range(&mut app, 0, 0);
|
||||
let lists = |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.para_at(0).list, text.para_at(1).list)
|
||||
};
|
||||
let numbered = |style| Some(ParaList::numbered(style));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let row = std::cell::Cell::new(egui::Rect::NOTHING);
|
||||
let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| {
|
||||
row.set(ui.horizontal(|ui| numbers_button(app, ui, 28.0)).response.rect);
|
||||
};
|
||||
super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let min = row.get().min;
|
||||
let (button, arrow) = (min + vec2(14.0, 14.0), min + vec2(28.0 + 7.0, 14.0));
|
||||
super::super::tests_appearance::click(&ctx, &mut app, button, &mut draw);
|
||||
assert_eq!(lists(&app), (numbered(NumberStyle::Arabic), None), "the caret's paragraph");
|
||||
assert_eq!(shared_para(&app, |p| p.list.and_then(|l| l.number)), Some(Some(NumberStyle::Arabic)), "the button shows pressed");
|
||||
let mut pick = |app: &mut VectorcraftApp, name: &str| {
|
||||
super::super::tests_appearance::click(&ctx, app, arrow, &mut draw);
|
||||
let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], &mut draw);
|
||||
let at = super::super::tests_appearance::text_rect(&texts, name).center();
|
||||
super::super::tests_appearance::click(&ctx, app, at, &mut draw);
|
||||
};
|
||||
for (name, style) in [
|
||||
("I, II, III", NumberStyle::UpperRoman),
|
||||
("a, b, c", NumberStyle::LowerLetters),
|
||||
("001, 002, 003", NumberStyle::ThreeDigits),
|
||||
("1, 2, 3", NumberStyle::Arabic),
|
||||
] {
|
||||
pick(&mut app, name);
|
||||
assert_eq!(lists(&app), (numbered(style), None), "{name}");
|
||||
}
|
||||
pick(&mut app, "None");
|
||||
assert_eq!(lists(&app), (None, None));
|
||||
app.session.set_tool_option("selectAll", &json!(true));
|
||||
app.run("type.applyNumbers", json!({})).unwrap();
|
||||
assert_eq!(lists(&app), (numbered(NumberStyle::Arabic), numbered(NumberStyle::Arabic)));
|
||||
// The Numbered List button shows pressed and the Bullets button doesn't, and the other way
|
||||
// round for a bulleted list.
|
||||
// Each button and the arrow beside it have the same name; only a button shows pressed.
|
||||
let pressed = |app: &mut VectorcraftApp| {
|
||||
let nodes = crate::widgets::accessibility_tests::tree(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
bullets_button(app, ui, 28.0);
|
||||
numbers_button(app, ui, 28.0);
|
||||
});
|
||||
});
|
||||
let on = |label| nodes.iter().any(|n| n.label() == Some(label) && n.toggled() == Some(egui::accesskit::Toggled::True));
|
||||
(on("Bullets"), on("Numbered List"))
|
||||
};
|
||||
assert_eq!(pressed(&mut app), (false, true));
|
||||
app.session.set_tool_option("selectAll", &json!(true));
|
||||
app.run("type.applyBullets", json!({})).unwrap();
|
||||
assert_eq!(pressed(&mut app), (true, false));
|
||||
app.session.set_tool_option("selectAll", &json!(true));
|
||||
app.run("type.applyNumbers", json!({})).unwrap();
|
||||
app.run("type.applyNumbers", json!({"number": "upperLetters"})).unwrap();
|
||||
assert_eq!(lists(&app).0, numbered(NumberStyle::UpperLetters));
|
||||
app.session.set_tool_option("selectAll", &json!(true));
|
||||
reset_panel(&mut app);
|
||||
assert_eq!(lists(&app), (None, None));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -233,6 +233,29 @@ fn selected_type_has_apply_bullets() {
|
||||
assert!(text.para_at(0).list.is_some());
|
||||
}
|
||||
|
||||
/// Selected type has Bullets and Numbering › Apply Numbers in the context menu and the Type menu,
|
||||
/// after Apply Bullets, which makes its paragraphs a numbered list; other art has no such item.
|
||||
#[test]
|
||||
fn selected_type_has_apply_numbers() {
|
||||
let mut app = app();
|
||||
let r = rect(&mut app, 0.0, 0.0);
|
||||
app.run("select.set", json!({"ids": [r.0]})).unwrap();
|
||||
let numbers = |app: &VectorcraftApp| menus::context_entries(app).into_iter().position(|e| e.command.as_deref() == Some("type.applyNumbers"));
|
||||
assert!(numbers(&app).is_none(), "a rectangle has no paragraphs");
|
||||
let t = NodeId(app.run("text.create", json!({"x": 10, "y": 200, "text": "Item"})).unwrap()["id"].as_u64().unwrap());
|
||||
app.run("select.set", json!({"ids": [t.0]})).unwrap();
|
||||
let entries = menus::context_entries(&app);
|
||||
let at = numbers(&app).expect("Apply Numbers");
|
||||
let e = &entries[at];
|
||||
assert_eq!((e.path.as_slice(), e.label.as_str(), e.enabled), (&["Bullets and Numbering".to_string()][..], "Apply Numbers", true));
|
||||
assert_eq!(entries.get(at.wrapping_sub(1)).and_then(|e| e.command.as_deref()), Some("type.applyBullets"));
|
||||
let typed = menus::menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("type.applyNumbers")).expect("in the Type menu");
|
||||
assert_eq!(typed.path, ["Type", "Bullets and Numbering"]);
|
||||
menus::invoke(&mut app, "type.applyNumbers", json!({}));
|
||||
let Some(NodeKind::Text(text)) = app.session.active().unwrap().doc.node(t).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!(text.para_at(0).list.and_then(|l| l.number), Some(vectorcraft_doc::text::NumberStyle::Arabic));
|
||||
}
|
||||
|
||||
/// Every label the context menu can show is a menu string, so complete catalogs must translate it.
|
||||
#[test]
|
||||
fn every_context_label_is_a_menu_string() {
|
||||
|
||||
+53
-32
@@ -4364,46 +4364,67 @@ The menu item is checked while the paragraph the Paragraph panel shows has it on
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"romanHangingPunctuation":true}}}
|
||||
```
|
||||
|
||||
## Bulleted lists
|
||||
## Bulleted and numbered lists
|
||||
|
||||
`text.setFormat {list: "none"|"bullet", bullet: "none"|"dot"|"dash"|"circle"|"square"}` (Paragraph panel › Bullets)
|
||||
makes the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / `end` range touches) a bulleted list
|
||||
or takes them out of one. `bullet` sets the bullet: a filled dot (•), a dash (–), a hollow circle (◦) or a filled
|
||||
square (▪). `list: "bullet"` keeps the bullet of a paragraph that is already in a list and gives the others the dot.
|
||||
`"none"` in either key turns the list off and leaves the indents as they are. An unknown value is an error and changes
|
||||
nothing. A paragraph that joins a list while its left and first-line indents are both 0 gets a left indent of 18 pt and a
|
||||
first-line indent of -18 pt, unless the same call sets `leftIndent` or `firstLineIndent`; other indents stay as they are.
|
||||
In a horizontal right-to-left list paragraph the indents are mirrored: the left and first-line indents are measured
|
||||
from the right edge, and the right indent from the left edge. Its bullet starts the first line at the right, and the
|
||||
lines it wraps to start at the right where the first line's text starts after the bullet. A right-to-left paragraph
|
||||
without a list keeps its left and first-line indents at the left, and vertical type keeps its indents as they are. In
|
||||
threaded type, a list paragraph that continues into the next frame starts that frame with its bullet again, as it
|
||||
starts it with its first-line indent.
|
||||
`text.setFormat {list: "none"|"bullet"|"number", bullet: "none"|"dot"|"dash"|"circle"|"square", number:
|
||||
"none"|"arabic"|"upperLetters"|"lowerLetters"|"upperRoman"|"lowerRoman"|"twoDigits"|"threeDigits"}` (Paragraph panel ›
|
||||
Bullets and Numbered List) makes the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / `end`
|
||||
range touches) a bulleted or numbered list or takes them out of one. `bullet` sets the bullet: a filled dot (•), a dash
|
||||
(–), a hollow circle (◦) or a filled square (▪). `list: "bullet"` keeps the bullet of a paragraph that is already in a
|
||||
list and gives the others the dot. `number` sets the number style: 1, 2, 3; A, B, C; a, b, c; I, II, III; i, ii, iii;
|
||||
01, 02, 03; or 001, 002, 003. `list: "number"` keeps the style of a paragraph that is already in a numbered list and
|
||||
gives the others 1, 2, 3. Without `list`, a `number` style makes a numbered list and a `bullet` makes a bulleted one; a
|
||||
numbered list keeps its bullet for when it is made bulleted again. `"none"` in any of the three keys turns the list off
|
||||
and leaves the indents as they are. An unknown value is an error and changes nothing. A paragraph that joins a list
|
||||
while its left and first-line indents are both 0 gets a left indent of 18 pt and a first-line indent of -18 pt, unless
|
||||
the same call sets `leftIndent` or `firstLineIndent`; other indents stay as they are, also when a list changes between
|
||||
bullets and numbers. In a horizontal right-to-left list paragraph the indents are mirrored: the left and first-line
|
||||
indents are measured from the right edge, and the right indent from the left edge. Its marker starts the first line at
|
||||
the right, and the lines it wraps to start at the right where the first line's text starts after the marker. A
|
||||
right-to-left paragraph without a list keeps its left and first-line indents at the left, and vertical type keeps its
|
||||
indents as they are. In threaded type, a list paragraph that continues into the next frame starts that frame with its
|
||||
bullet or number again, as it starts it with its first-line indent.
|
||||
|
||||
The layout draws the bullet before the first line of each list paragraph that has text, in the font, size, scales and
|
||||
color of the paragraph's first character, without that character's baseline shift, rotation, superscript or subscript,
|
||||
tracking, manual kerning and tate-chu-yoko. The bullet is not a character of the text: copied text leaves it out, the
|
||||
caret before the paragraph's first character stands at the text, a selection does not highlight the bullet, and Touch
|
||||
Type moves that character without the bullet. The text after it starts at the next tab stop, the left indent counting
|
||||
as a stop when no explicit stop comes before it, measured from the right in a right-to-left list paragraph. The lines
|
||||
the paragraph wraps to start at the left indent. Type on a path draws the bullet and the same gap before the
|
||||
paragraph's text; a right-to-left item on a path has its bullet at the right of its text. Justification and Optical
|
||||
Margin Alignment leave the bullet and the gap after it as they are, and an underline or strikethrough starts at the
|
||||
text. Return at the end of a list item starts another item in the same list. The list is saved with the paragraph
|
||||
(`list` in `para`, for example `{"bullet": "square"}`) only when it is set, and paragraph styles carry it: a style
|
||||
captured from a paragraph without a list turns the list off. Reset Panel turns it off. The change is one undo step.
|
||||
The layout draws the marker (the bullet, or the number and its period) before the first line of each list paragraph
|
||||
that has text, in the font, size, scales and color of the paragraph's first character, without that character's
|
||||
baseline shift, rotation, superscript or subscript, tracking, manual kerning and tate-chu-yoko. The marker is not a
|
||||
character of the text: copied text leaves it out, the caret before the paragraph's first character stands at the text,
|
||||
a selection does not highlight the marker, and Touch Type moves that character without the marker. The text after it
|
||||
starts at the next tab stop, the left indent counting as a stop when no explicit stop comes before it, measured from the
|
||||
right in a right-to-left list paragraph; a number that reaches past the left indent takes the text to the next tab stop
|
||||
after it. The lines the paragraph wraps to start at the left indent. Type on a path draws the marker and the same gap
|
||||
before the paragraph's text; a right-to-left item on a path has its marker at the right of its text. Justification and
|
||||
Optical Margin Alignment leave the marker and the gap after it as they are, and an underline or strikethrough starts at
|
||||
the text. Return at the end of a list item starts another item in the same list. The list is saved with the paragraph
|
||||
(`list` in `para`, for example `{"bullet": "square"}` or `{"bullet": "dot", "number": "upperRoman"}`) only when it is
|
||||
set, and paragraph styles carry it: a style captured from a paragraph without a list turns the list off. Reset Panel
|
||||
turns it off. The change is one undo step.
|
||||
|
||||
The canvas, EPS, PDF and Type › Create Outlines draw the bullet; in PDF it is a path, not text. Live SVG text writes the
|
||||
bullet's character before the paragraph's text, except type on a path with bullets, which it writes as outlines with a
|
||||
warning. DXF writes the bullet as a TEXT entity of its own. Legacy `.ai` writes the
|
||||
paragraph's text without the bullet and warns that bulleted lists are left out.
|
||||
A numbered paragraph's number counts the numbered paragraphs of its story up to and including it: the count runs through
|
||||
consecutive list paragraphs, through the frames of threaded type in thread order, and starts again at 1 after a
|
||||
paragraph in no list. An empty paragraph has no number and is not counted, and a bulleted paragraph keeps its bullet and
|
||||
is not counted. Letters run from A to Z, then AA to ZZ, then AAA; roman numerals use IV, IX, XL, XC, CD and CM; 01 and
|
||||
001 keep every digit past 99 and 999; letters and roman numerals past 3999 are written in Arabic digits. In a
|
||||
right-to-left paragraph the number reads left to right with its period at its left. Each threaded frame saves the count
|
||||
that its earlier frames carry into it (`listCount`, only when it is not 0). Type that is in no thread counts from 1:
|
||||
after Remove Threading or Release Selection, in a frame converted to point type, and in every copy of a frame (pasted,
|
||||
duplicated, copied in the Layers panel, or taken out of a symbol or a placed file). A symbol made of a thread keeps the
|
||||
numbers in the symbol's art.
|
||||
|
||||
The canvas, EPS, PDF and Type › Create Outlines draw the marker; in PDF it is a path, not text. Live SVG text writes the
|
||||
marker's characters before the paragraph's text, except type on a path with a list, which it writes as outlines with a
|
||||
warning. DXF writes the marker as a TEXT entity of its own, its characters in the order they are drawn. Legacy `.ai`
|
||||
writes the paragraph's text without the marker and warns that bulleted and numbered lists are left out.
|
||||
|
||||
Type › Bullets and Numbering › Apply Bullets and the canvas context menu's Bullets and Numbering › Apply Bullets run
|
||||
`type.applyBullets {bullet?}` on the selected type's paragraphs, or on those the Type tool's selection touches: without
|
||||
`bullet` as `text.setFormat {list: "bullet"}`, with it as `text.setFormat {bullet}`.
|
||||
`type.applyBullets {bullet?}` on the selected type's paragraphs, or on those the Type tool's selection touches. Without
|
||||
`bullet` it runs `text.setFormat {list: "bullet"}`, and with it it runs `text.setFormat {bullet}`. Apply Numbers in the
|
||||
same menus runs `type.applyNumbers {number?}`. Without `number` it runs `text.setFormat {list: "number"}`, and with it
|
||||
it runs `text.setFormat {number}`.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"bullet":"square"}}}
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"number":"upperRoman"}}}
|
||||
```
|
||||
|
||||
## Small Caps, Superscript and Subscript
|
||||
|
||||
@@ -775,7 +775,8 @@ 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. Numbered lists, the Bullets and Numbering dialog, levels, list detection while typing and Convert to Text 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, list detection while typing 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.
|
||||
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.
|
||||
The Tabs panel's ruler is numbered in the General unit with the canvas rulers' label steps and a tick every tenth of a step. With the panel menu's Snap to Unit on, or Shift held, a tab stop placed by a click on the ruler or dragged on it lands on the nearest tick (`text.tabs.set`).
|
||||
|
||||
Reference in New Issue
Block a user