From bc041e15ef31428bd245c390055a9a91c9f633af Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:25:19 +0700 Subject: [PATCH] Type: the Numbered List button numbers paragraphs 1, 2, 3 Squashed from #3960 onto main. --- ASSETS.md | 1 + assets/icons/dc-para-numbers.svg | 1 + crates/cad/src/scene.rs | 9 +- crates/cad/src/tests.rs | 24 +++ crates/doc/src/text.rs | 190 ++++++++++++++++++++- crates/engine/src/cmd/panelcmds.rs | 38 ++++- crates/engine/src/cmd/textedit.rs | 2 + crates/engine/src/cmd/threads.rs | 213 +++++++++++++++++++++++- crates/engine/src/cmd/typecmd.rs | 73 ++++++++ crates/engine/src/lib.rs | 3 + crates/eps/src/legacy_ai.rs | 2 +- crates/eps/src/tests_legacy_ai.rs | 25 +-- crates/svg/src/export.rs | 5 +- crates/svg/tests/it/text_layout.rs | 41 ++++- crates/text/src/layout.rs | 55 ++++-- crates/text/src/tests.rs | 97 +++++++++++ crates/text/src/thread.rs | 65 +++++++- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/menus.rs | 13 +- crates/ui-egui/src/panels/paragraph.rs | 153 ++++++++++++++++- crates/ui-egui/src/tests_contextmenu.rs | 23 +++ docs/mcp.md | 85 ++++++---- docs/target-app-parity.md | 3 +- 47 files changed, 1075 insertions(+), 95 deletions(-) create mode 100644 assets/icons/dc-para-numbers.svg diff --git a/ASSETS.md b/ASSETS.md index 9f254a76d..8e096c1c3 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -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 | | diff --git a/assets/icons/dc-para-numbers.svg b/assets/icons/dc-para-numbers.svg new file mode 100644 index 000000000..27eb3cc2b --- /dev/null +++ b/assets/icons/dc-para-numbers.svg @@ -0,0 +1 @@ + diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index ab9137050..1e7baac26 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -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 }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 92f701d47..46b09f5ff 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -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::>() + }; + 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::>(), ["שלום", ".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() { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 275eea4bb..60b5031e9 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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, } 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, empty: bool, count: &mut u32) -> Option { + 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 { /// #[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, @@ -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::>(); + 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> = [(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::(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()); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index d48b76492..b2a2aaec9 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\" (Paragraph panel › Bullets: each paragraph of a bulleted list starts with its bullet, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the bullet, the left indent counting as a stop; list \"bullet\" keeps a list's bullet, else a dot; \"none\" in either key turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), 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: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -267,6 +267,17 @@ const BULLETS: [(&str, vectorcraft_doc::text::Bullet); 4] = [ ("square", vectorcraft_doc::text::Bullet::Square), ]; +/// `text.setFormat`'s `number` values and the number styles they pick. +const NUMBERS: [(&str, vectorcraft_doc::text::NumberStyle); 7] = [ + ("arabic", vectorcraft_doc::text::NumberStyle::Arabic), + ("upperLetters", vectorcraft_doc::text::NumberStyle::UpperLetters), + ("lowerLetters", vectorcraft_doc::text::NumberStyle::LowerLetters), + ("upperRoman", vectorcraft_doc::text::NumberStyle::UpperRoman), + ("lowerRoman", vectorcraft_doc::text::NumberStyle::LowerRoman), + ("twoDigits", vectorcraft_doc::text::NumberStyle::TwoDigits), + ("threeDigits", vectorcraft_doc::text::NumberStyle::ThreeDigits), +]; + /// A justification spacing of `text.setFormat` (`wordSpacing`, `letterSpacing`, `glyphScaling`): /// `[min, desired, max]` or `{min?, desired?, max?}`, in percent. The values not given are kept. fn spacing_param(p: &Value, key: &str, c: &str) -> Result; 3]>> { @@ -323,6 +334,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "spaceAfter", "list", "bullet", + "number", "hyphenate", "wordsLongerThan", "afterFirst", @@ -421,11 +433,18 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`kashida` must be \"none\", \"short\", \"medium\", \"long\" or \"stylistic\"")), }), }; - // Bullets: `list` turns a bulleted list on or off and `bullet` picks its bullet; "none" in - // either turns the list off. - let list = super::object::choice(p, C, "list", &["none", "bullet"])?; + // Bullets and numbers: `list` turns a bulleted or numbered list on or off, `bullet` picks the + // bullet and `number` the number style; "none" in any of them turns the list off. + let list = super::object::choice(p, C, "list", &["none", "bullet", "number"])?; let bullet = super::object::choice(p, C, "bullet", &["none", "dot", "dash", "circle", "square"])? .map(|b| BULLETS.iter().find(|(k, _)| *k == b).map(|&(_, bullet)| bullet)); + let number = super::object::choice( + p, + C, + "number", + &["none", "arabic", "upperLetters", "lowerLetters", "upperRoman", "lowerRoman", "twoDigits", "threeDigits"], + )? + .map(|n| NUMBERS.iter().find(|(k, _)| *k == n).map(|&(_, style)| style)); let indents_given = num("leftIndent").is_some() || num("firstLineIndent").is_some(); let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); @@ -523,10 +542,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = num("spaceAfter") { para.space_after = v.clamp(-MAX_PARA, MAX_PARA); } - if list.is_some() || bullet.is_some() { - let on = list != Some("none") && bullet != Some(None); + if list.is_some() || bullet.is_some() || number.is_some() { + let on = list != Some("none") && bullet != Some(None) && number != Some(None); let had = para.list; - para.list = on.then(|| vectorcraft_doc::text::ParaList::bulleted(bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default())); + // A numbered list for list "number", or without `list` for a number style. + let numbered = list == Some("number") || (list.is_none() && number.is_some()); + para.list = on.then(|| vectorcraft_doc::text::ParaList { + bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), + number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), + }); // A paragraph that joins a list with no left or first-line indent gets a // hanging indent, unless the call sets an indent. if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 0c29ac4e7..c7267d4de 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -900,6 +900,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { 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 { optical_margin: false, system_layout: t.system_layout, fractional_widths: t.fractional_widths, + list_count: 0, cached_bounds: None, cached_baselines: Vec::new(), }; diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 23e2bb1af..3131887b4 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -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) { + 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 = 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 { @@ -478,6 +514,7 @@ fn release(s: &mut Session, _: &Value) -> Result { 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::>().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::>() + }; + let all = |from: usize| (from..=10).map(|n| format!("{n}.")).collect::>(); + 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 = 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::>().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::>(), [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::>().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:?}"); + } } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 69a155840..bc01e71f7 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -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::>() + }; + 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() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c26f734ad..e290abebc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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)); diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 5a20a0804..a2d24a07f 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -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)"; diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index 58e1b2850..ee55b0a5d 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -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] diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 179cde321..b57ed8821 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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 `` holds the characters of the text, not the bullets drawn before them. + // A `` 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); diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index 3b8dd79c6..76c30a107 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -253,7 +253,12 @@ fn canvas_glyph_positions(d: &Document) -> (Vec, Vec> = 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(" { } } - /// 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, para: &ParaStyle, rtl: bool, sg: &mut Vec) -> usize { - let Some(list) = para.list.filter(|_| !pr.is_empty()) else { return 0 }; - let text = list.marker(); + fn prepend_marker(&self, pr: &Range, para: &ParaStyle, text: Option<&str>, rtl: bool, sg: &mut Vec) -> usize { + let Some(text) = text.filter(|_| para.list.is_some() && !pr.is_empty()) else { return 0 }; let st = vectorcraft_doc::text::ParaList::marker_style(self.style_at(pr.start)); let Some(run) = self.runs.iter().position(|(r, _)| r.start <= pr.start && pr.start < r.end) else { return 0 }; // The paragraph's direction: the marker starts a right-to-left paragraph at its right. let level = if rtl { Level::rtl() } else { Level::ltr() }; let mut marker = Vec::with_capacity(text.len() + 1); - shape_range(self.db, &text, 0..text.len(), &[(0..text.len(), &st)], &[None], &self.opts.features, &vec![level; text.len()], &mut marker); + shape_range(self.db, text, 0..text.len(), &[(0..text.len(), &st)], &[None], &self.opts.features, &vec![level; text.len()], &mut marker); + if rtl { + // [`visual_order`] draws a right-to-left marker's glyphs in reverse: the number's + // glyphs go in reversed, so that it reads left to right, and the period after them. + let number = marker.iter().take_while(|g| text.get(g.byte..).is_some_and(|s| !s.starts_with('.'))).count(); + marker[..number].reverse(); + } for g in &mut marker { g.byte = pr.start; g.len = 0; @@ -2016,6 +2023,14 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pe compose(sg, &width, &start_credit, &cands, flex, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, ¶ms(loose))) } +/// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers +/// counted through consecutive list paragraphs from [`TextObject::list_count`], as +/// [`vectorcraft_doc::text::ParaList::next_marker`] counts them. +fn list_markers(t: &TextObject, paras: &[Range]) -> Vec> { + 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], 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], t: &TextObject, regions: Optio // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how // far a justified line may move each one from it. let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); - // Bullets: the list marker's glyphs `0..marker` start the paragraph. Justification and - // Optical Margin Alignment leave them as they are. - let marker = cx.prepend_marker(pr, para, rtl, &mut sg); + // Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph. + // Justification and Optical Margin Alignment leave them as they are. + let marker = cx.prepend_marker(pr, para, marker_text, rtl, &mut sg); if marker > 0 { flex.splice(0..0, vec![Flex::default(); marker]); if !compressed.is_empty() { @@ -2452,8 +2469,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], 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], 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], 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() }); } } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f9d96deed..7373db683 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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 { + 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]) -> TextObject { + let mut t = area(text, style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), Justify::Left); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let styles = lists.iter().map(|&list| vectorcraft_doc::ParaStyle { list, ..t.para.clone() }).collect(); + t.set_paragraph_styles(styles); + t +} + +/// Numbered lists: each list paragraph starts with its number and a period, counted through the +/// list paragraphs before it, and its text starts at the left indent after the number. An empty +/// paragraph has no number and is not counted, a bulleted paragraph keeps its bullet and is not +/// counted, and a paragraph in no list starts the count again. Each number style writes its +/// numbers and rolls over, and a number wider than the indent takes the text to the next tab stop. +#[test] +fn numbered_list_paragraphs_are_numbered_in_order() { + use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; + let numbered = Some(ParaList::numbered(NumberStyle::Arabic)); + let t = listed("One\nTwo\nThree", &[numbered; 3]); + assert_eq!(marker_texts(&t), ["1.", "2.", "3."]); + let lay = layout(db(), &t); + for (k, m) in lay.markers.iter().enumerate() { + let number = &lay.glyphs[m.glyphs.start]; + let line = &lay.lines[number.line]; + let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + assert!(number.origin.x.abs() < 1e-6 && (text.origin.x - 18.0).abs() < 1e-6, "item {k}: {} {}", number.origin.x, text.origin.x); + assert_eq!((m.tab, lay.marker_text(m.tab)), (m.glyphs.end - 1, Some("\t")), "item {k}"); + } + assert_eq!(marker_texts(&listed("One\n\nTwo", &[numbered; 3])), ["1.", "2."]); + assert_eq!(marker_texts(&listed("A\nB\nC\nD\nE", &[numbered, numbered, None, numbered, numbered])), ["1.", "2.", "1.", "2."]); + assert_eq!(marker_texts(&listed("A\nB\nC", &[numbered, Some(ParaList::bulleted(Bullet::Dash)), numbered])), ["1.", "\u{2013}", "2."]); + // The count goes on from the earlier frames of a thread. + let mut t = listed("One\nTwo", &[numbered; 2]); + t.list_count = 9; + assert_eq!(marker_texts(&t), ["10.", "11."]); + // Each style, from the count before its rollover. + for (style, before, numbers) in [ + (NumberStyle::Arabic, 0, ["1.", "2."]), + (NumberStyle::UpperLetters, 25, ["Z.", "AA."]), + (NumberStyle::LowerLetters, 25, ["z.", "aa."]), + (NumberStyle::UpperRoman, 3, ["IV.", "V."]), + (NumberStyle::LowerRoman, 8, ["ix.", "x."]), + (NumberStyle::TwoDigits, 98, ["99.", "100."]), + (NumberStyle::ThreeDigits, 998, ["999.", "1000."]), + ] { + let mut t = listed("One\nTwo", &[Some(ParaList::numbered(style)); 2]); + t.list_count = before; + assert_eq!(marker_texts(&t), numbers, "{style:?}"); + } + // "1000." is wider than the 18 pt before the left indent: the text goes to the next default stop. + let mut wide = listed("One", &[Some(ParaList::numbered(NumberStyle::ThreeDigits))]); + wide.list_count = 999; + let lay = layout(db(), &wide); + let number_width: f64 = lay.glyphs[..lay.markers[0].tab].iter().map(|g| g.advance).sum(); + let text = lay.glyphs.iter().find(|g| g.len > 0).unwrap(); + assert!(number_width > 18.0 && (text.origin.x - 36.0).abs() < 1e-6, "{number_width} {}", text.origin.x); + // Type on a path numbers its paragraphs too. + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let mut on_path = listed("One\nTwo", &[numbered; 2]); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + assert_eq!(marker_texts(&on_path), ["1.", "2."]); +} + +/// Numbered lists: a right-to-left list paragraph's number ends its first line at the right and +/// reads left to right, with its period at its left and the gap before the text left of that. +#[test] +fn a_right_to_left_numbered_paragraph_has_its_period_left_of_its_number() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let numbered = Some(ParaList::numbered(NumberStyle::Arabic)); + // The glyphs of "1", "2" and "." in a left-to-right "12.". + let mut ltr = listed("One", &[numbered]); + ltr.list_count = 11; + let lay = layout(db(), <r); + let gids: Vec = 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::>(), [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 { diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index a1fbf9093..6d5fb8617 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -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, pub paras: Vec, + 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 { 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::>() + }; + // Whole paragraphs: the first frame ends with a paragraph break. + let text = (1..=8).map(|i| format!("Item {i}")).collect::>().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::>(), [0, first.len() as u32]); + let second = numbers(&b, &parts[1]); + let expected: Vec = (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(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 76d8f99fa..316b3c4ae 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -342,6 +342,8 @@ font-technology Variable Променливи Bullets Булети Bullets and Numbering Булети и номериране Apply Bullets Прилагане на булети + Numbered List Номериран списък + Apply Numbers Прилагане на номериране Filled Dot Плътна точка Dash Тире Hollow Circle Празен кръг diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 103492f50..437c34735 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index eb7905143..83e20e6e5 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3af08e9c0..01808ec66 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c0d6d1806..3a209ff19 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -330,6 +330,8 @@ font-technology Variable Μεταβλητές Bullets Κουκκίδες Bullets and Numbering Κουκκίδες και αρίθμηση Apply Bullets Εφαρμογή κουκκίδων + Numbered List Αριθμημένη λίστα + Apply Numbers Εφαρμογή αρίθμησης Filled Dot Γεμάτη κουκκίδα Dash Παύλα Hollow Circle Κενός κύκλος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 22755e74b..9fb9d0315 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 81cf5506f..26e6baf3e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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ä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b1c979f49..2eb21bd71 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 53d23106e..11782d2b4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a70582c8f..1ce366812 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d20a4121f..f2e260f4e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 58910c75f..0dd09092a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -315,6 +315,8 @@ font-technology Variable バリアブル Bullets 箇条書き Bullets and Numbering 箇条書きと番号付け Apply Bullets 箇条書きを適用 + Numbered List 番号付きリスト + Apply Numbers 番号付けを適用 Filled Dot 黒丸 Dash ダッシュ Hollow Circle 白丸 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e4f256d06..6b4e142f8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -246,6 +246,8 @@ Bullets ბურთულები Bullets and Numbering ბურთულები და დანომრვა Apply Bullets ბურთულების გამოყენება + Numbered List დანომრილი სია + Apply Numbers დანომრვის გამოყენება Filled Dot სავსე წერტილი Dash ტირე Hollow Circle ცარიელი წრე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 34b4e8053..74bc82294 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -311,6 +311,8 @@ font-technology Variable 가변 Bullets 글머리 기호 Bullets and Numbering 글머리 기호 및 번호 매기기 Apply Bullets 글머리 기호 적용 + Numbered List 번호 매기기 목록 + Apply Numbers 번호 매기기 적용 Filled Dot 채운 점 Dash 대시 Hollow Circle 빈 원 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a05db9f2d..aa420b254 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 56f68ce86..d43f4ea4a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 85c269f62..4ae911c69 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 32b341dbd..67830997d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -353,6 +353,8 @@ font-technology Variable Вариативные Bullets Маркеры Bullets and Numbering Маркеры и нумерация Apply Bullets Применить маркеры + Numbered List Нумерованный список + Apply Numbers Применить нумерацию Filled Dot Закрашенная точка Dash Тире Hollow Circle Пустой круг diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ff43b766f..745eb0557 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index eb63ed592..77fc87a49 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d49c9f499..3589bfdb8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -349,6 +349,8 @@ font-technology Variable Варіативні Bullets Маркери Bullets and Numbering Маркери та нумерація Apply Bullets Застосувати маркери + Numbered List Нумерований список + Apply Numbers Застосувати нумерацію Filled Dot Заповнена крапка Dash Тире Hollow Circle Порожнє коло diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 437422e55..689389cee 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d097be59f..ebbf60a01 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -304,6 +304,8 @@ Bullets 项目符号 Bullets and Numbering 项目符号和编号 Apply Bullets 应用项目符号 + Numbered List 编号列表 + Apply Numbers 应用编号 Filled Dot 实心圆点 Dash 短划线 Hollow Circle 空心圆 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5ad9dffb0..97710733c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -317,6 +317,8 @@ font-technology Variable 可變 Bullets 項目符號 Bullets and Numbering 項目符號與編號 Apply Bullets 套用項目符號 + Numbered List 編號清單 + Apply Numbers 套用編號 Filled Dot 實心圓點 Dash 短破折號 Hollow Circle 空心圓 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index f19e0a193..33ddde543 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -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")), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9bc8dcf5f..7191f77f1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 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)> { ], ), 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 { } // 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"), diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index a60454f6e..3e81bc893 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -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 Result { + 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>().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. diff --git a/crates/ui-egui/src/tests_contextmenu.rs b/crates/ui-egui/src/tests_contextmenu.rs index 6ddde2ac5..dc1e898b0 100644 --- a/crates/ui-egui/src/tests_contextmenu.rs +++ b/crates/ui-egui/src/tests_contextmenu.rs @@ -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() { diff --git a/docs/mcp.md b/docs/mcp.md index b7f9663d9..627ba3839 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9e8728eb..9a0e5ddb7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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`).