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:
BFlat
2026-10-24 19:05:45 +07:00
committed by GitHub
47 changed files with 1075 additions and 95 deletions
+1
View File
@@ -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 | |
+1
View File
@@ -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

+7 -2
View File
@@ -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 };
+24
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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(),
};
+212 -1
View File
@@ -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:?}");
}
}
+73
View File
@@ -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() {
+3
View File
@@ -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));
+1 -1
View File
@@ -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)";
+14 -11
View File
@@ -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]
+3 -2
View File
@@ -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);
+40 -1
View File
@@ -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
View File
@@ -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, &params(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, &params(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, &para.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() });
}
}
}
+97
View File
@@ -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(), &ltr);
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 {
+62 -3
View File
@@ -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();
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -330,6 +330,8 @@ font-technology Variable Μεταβλητές
Bullets Κουκκίδες
Bullets and Numbering Κουκκίδες και αρίθμηση
Apply Bullets Εφαρμογή κουκκίδων
Numbered List Αριθμημένη λίστα
Apply Numbers Εφαρμογή αρίθμησης
Filled Dot Γεμάτη κουκκίδα
Dash Παύλα
Hollow Circle Κενός κύκλος
1 # Ελληνικά (el) — VectorCraft
330
331
332
333
334
335
336
337
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
287
288
289
290
291
292
293
294
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -349,6 +349,8 @@ font-technology Variable Варіативні
Bullets Маркери
Bullets and Numbering Маркери та нумерація
Apply Bullets Застосувати маркери
Numbered List Нумерований список
Apply Numbers Застосувати нумерацію
Filled Dot Заповнена крапка
Dash Тире
Hollow Circle Порожнє коло
1 # Ukrainian (uk) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation.
349
350
351
352
353
354
355
356
+2
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
409
410
411
412
413
414
415
416
+2
View File
@@ -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.
+2
View File
@@ -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.
+1
View File
@@ -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")),
+10 -3
View File
@@ -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"),
+148 -5
View File
@@ -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.
+23
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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`).