From 2a2e63856c9f9790d98276fa454704b25346107b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 20:27:58 -0700 Subject: [PATCH] Type: the Paragraph panel's Bullets button turns paragraphs into a bulleted list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VectorCraft had no lists. A bullet could only be typed as a character, and the tab after it did not stop at the left indent. The reference app turns paragraphs into a bulleted list from the Paragraph panel, the context menu and Type › Bullets and Numbering › Apply Bullets. text.setFormat takes list ("none" or "bullet") and bullet ("none", "dot", "dash", "circle" or "square"). The layout draws the bullet before the first line of each list paragraph that has text, in the font, size and color of its first character, and starts the text at the next tab stop, with the left indent counted as a stop. Wrapped lines start at the left indent, and type on a path draws the bullet too. In a horizontal right-to-left list paragraph the indents are mirrored: the bullet hangs at the right, and the wrapped lines start at the right where the first line's text starts after the bullet. The bullet is not a character of the text. A paragraph whose left and first-line indents are both 0 gets 18 pt and -18 pt when it joins a list. Return continues the list, paragraph styles carry it, Reset Panel turns it off, and the native file saves it only when it is set. The Paragraph panel has a Bullets button with a list of the five presets, and Type › Bullets and Numbering › Apply Bullets and the context menu run type.applyBullets. Live SVG text writes the bullet's character, and SVG writes type on a path with bullets as outlines. DXF writes the bullet as a TEXT entity of its own, and legacy .ai export warns that bulleted lists are left out. The caret, selections, Touch Type and Show Hidden Characters skip the bullet. Live SVG text of horizontally scaled type now writes each tab in a tspan of its own. Before, the characters before a tab shared its tspan, and its textLength made a viewer stretch them across the tab's gap. Docs: docs/mcp.md, docs/target-app-parity.md. --- ASSETS.md | 1 + assets/icons/dc-para-bullets.svg | 1 + crates/cad/src/scene.rs | 13 +- crates/cad/src/tests.rs | 36 +++ crates/doc/src/text.rs | 83 ++++++ crates/engine/src/cmd/object.rs | 6 +- crates/engine/src/cmd/panelcmds.rs | 29 ++- crates/engine/src/cmd/textedit.rs | 15 +- crates/engine/src/cmd/textstyles.rs | 3 +- crates/engine/src/cmd/typecmd.rs | 86 ++++++ crates/engine/src/tests_inline.rs | 17 ++ crates/engine/src/tests_textedit.rs | 23 ++ crates/eps/src/legacy_ai.rs | 4 +- crates/eps/src/tests_legacy_ai.rs | 15 ++ crates/svg/src/export.rs | 63 ++++- crates/svg/tests/text_layout.rs | 105 +++++++- crates/text/src/layout.rs | 237 ++++++++++++++--- crates/text/src/lib.rs | 65 ++++- crates/text/src/tests.rs | 333 ++++++++++++++++++++++++ crates/text/src/tests_ruby.rs | 17 ++ crates/tools/src/touchtype.rs | 50 +++- crates/ui-egui/src/canvas.rs | 13 +- crates/ui-egui/src/i18n/bg.tsv | 7 + crates/ui-egui/src/i18n/cs.tsv | 7 + crates/ui-egui/src/i18n/da.tsv | 7 + crates/ui-egui/src/i18n/de.tsv | 7 + crates/ui-egui/src/i18n/el.tsv | 7 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/fi.tsv | 7 + crates/ui-egui/src/i18n/fr.tsv | 7 + crates/ui-egui/src/i18n/hu.tsv | 7 + crates/ui-egui/src/i18n/id.tsv | 7 + crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/ka.tsv | 7 + crates/ui-egui/src/i18n/ko.tsv | 7 + crates/ui-egui/src/i18n/nl.tsv | 7 + crates/ui-egui/src/i18n/pl.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/ru.tsv | 7 + crates/ui-egui/src/i18n/sv.tsv | 7 + crates/ui-egui/src/i18n/tr.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/vi.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/menus.rs | 13 + crates/ui-egui/src/panels/paragraph.rs | 149 ++++++++++- crates/ui-egui/src/tests_contextmenu.rs | 20 ++ docs/mcp.md | 42 +++ docs/target-app-parity.md | 1 + 52 files changed, 1513 insertions(+), 96 deletions(-) create mode 100644 assets/icons/dc-para-bullets.svg diff --git a/ASSETS.md b/ASSETS.md index f8121c35b..02c6d3b87 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -169,6 +169,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | | | `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-para-bullets.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three dots, each before a line | MIT OR Apache-2.0 | | | `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-justify-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/assets/icons/dc-para-bullets.svg b/assets/icons/dc-para-bullets.svg new file mode 100644 index 000000000..b87e67860 --- /dev/null +++ b/assets/icons/dc-para-bullets.svg @@ -0,0 +1 @@ + diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 297f9f950..71279c31f 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -855,11 +855,18 @@ impl<'a> Scene<'a> { let angle = b.atan2(a).to_degrees(); for line in &layout.lines { let Some(glyphs) = layout.glyphs.get(line.glyph_start..line.glyph_end) else { continue }; - for run in glyphs.chunk_by(|x, y| x.run == y.run) { - let Some(first) = run.first() else { continue }; + // A list marker is a TEXT entity of its own, holding the marker's characters where its + // characters' glyphs are drawn (not its tab's). + let marked: Vec<(usize, &vectorcraft_text::PositionedGlyph, Option<&vectorcraft_text::ListMarker>)> = + glyphs.iter().enumerate().map(|(k, g)| (line.glyph_start + k, g, layout.marker_of(line.glyph_start + k))).collect(); + for chunk in marked.chunk_by(|x, y| x.1.run == y.1.run && x.2.is_some() == y.2.is_some()) { + let run: Vec<&vectorcraft_text::PositionedGlyph> = + chunk.iter().filter(|(k, _, m)| m.is_none_or(|m| m.tab != *k)).map(|(_, g, _)| *g).collect(); + 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 Some(s) = plain.get(lo..hi).map(str::trim_end).filter(|s| !s.trim().is_empty()) else { continue }; + 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 }; 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 95be8540e..5249d33d9 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -456,6 +456,42 @@ fn type_is_text_or_outlines() { } } +/// A list paragraph's bullet is a TEXT entity of its own where it is drawn, and the paragraph's +/// text starts after it, at the left indent. +#[test] +fn a_bullet_is_text_of_its_own_before_its_paragraph() { + let mut d = text_doc("Item", 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(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + } + let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert_eq!(kinds(&out), ["TEXT", "TEXT"]); + assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("\u{2022}"), Some("Item"))); + assert_eq!((all(&e[0], 10)[0], all(&e[1], 10)[0]), (10.0, 28.0), "the bullet at the anchor, the text 18 pt on"); + // In a right-to-left paragraph the bullet is drawn after the text, at the right, and its tab + // lies between the two. + let mut d = text_doc("שלום", 20.0); + let id = d.layers[0].children().unwrap()[0].id; + let mut bullet_x = f64::NAN; + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let m = &lay.markers[0]; + let bullet = m.glyphs.clone().find(|&i| i != m.tab).unwrap(); + assert!(lay.glyphs[m.tab].origin.x < lay.glyphs[bullet].origin.x, "the tab before the bullet"); + bullet_x = 10.0 + lay.glyphs[bullet].origin.x; + } + let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert_eq!(kinds(&out), ["TEXT", "TEXT"]); + assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("שלום"), Some("\u{2022}"))); + 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]); +} + /// 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 af2ead527..275eea4bb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -481,6 +481,80 @@ fn default_align_on() -> char { /// Distance between default tab stops when no explicit stop applies (½ inch). pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; +/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Bullet { + /// A filled dot (•). + #[default] + Dot, + /// A dash (–). + Dash, + /// A hollow circle (◦). + Circle, + /// A filled square (▪). + Square, +} + +impl Bullet { + /// The character the layout draws for the bullet. + pub fn character(self) -> char { + match self { + Bullet::Dot => '\u{2022}', + Bullet::Dash => '\u{2013}', + Bullet::Circle => '\u{25E6}', + Bullet::Square => '\u{25AA}', + } + } +} + +/// 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 +/// 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 +/// out as their defaults, and a build ignores the fields it doesn't know. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct ParaList { + /// The bullet each paragraph of the list starts with. + pub bullet: Bullet, +} + +impl ParaList { + /// A bulleted list with `bullet`. + pub fn bulleted(bullet: Bullet) -> Self { + Self { bullet } + } + + /// The characters of the marker the layout draws before the paragraph. + pub fn marker(&self) -> String { + self.bullet.character().to_string() + } + + /// The style the marker is set in: `first` (the style of the paragraph's first character) + /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, + /// tate-chu-yoko, underline and strikethrough. + pub fn marker_style(first: &CharStyle) -> CharStyle { + CharStyle { + baseline_shift: 0.0, + rotation: 0.0, + position: CharPosition::Normal, + tracking: 0.0, + kerning: None, + tate_chu_yoko: false, + underline: false, + strikethrough: false, + ..first.clone() + } + } +} + +/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the +/// marker hangs in the first line's 18 pt before the text. +pub const LIST_HANGING_INDENT: f64 = 18.0; + /// Paragraph composer (Paragraph panel menu): how lines are broken. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -674,6 +748,9 @@ pub struct ParaStyle { pub composer: Composer, /// Tab stops (Tabs panel), sorted by position. pub tabs: Vec, + /// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not + /// in a list. + pub list: Option, /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. pub style_name: Option, /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made @@ -762,6 +839,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "Vec::is_empty")] tabs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] + list: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] style_name: Option, #[serde(default, skip_serializing_if = "Mojikumi::is_none")] mojikumi: Mojikumi, @@ -798,6 +877,7 @@ impl From for ParaStyleFile { justification, composer, tabs, + list, style_name, mojikumi, direction, @@ -821,6 +901,7 @@ impl From for ParaStyleFile { justification, composer, tabs, + list, style_name, mojikumi, direction, @@ -848,6 +929,7 @@ impl From for ParaStyle { hyphenation, composer, tabs, + list, style_name, mojikumi, direction, @@ -870,6 +952,7 @@ impl From for ParaStyle { justification, composer, tabs, + list, style_name, mojikumi, direction, diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 93cdfd74a..8812da20b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1290,9 +1290,9 @@ fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result { } } -/// Param `key` of the Align commands and Shear: one of `values`, or `None` when it is missing or -/// null. Any other value is an error that lists `values`. -fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { +/// Param `key` of the Align commands, Shear and `text.setFormat`'s lists: one of `values`, or +/// `None` when it is missing or null. Any other value is an error that lists `values`. +pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; match v.as_str() { Some(s) if values.contains(&s) => Ok(Some(s)), diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2f03cb27f..b99e6d9b0 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), 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\", 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)", has_doc, set_format ), @@ -258,6 +258,14 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector /// Largest indent or paragraph spacing (points; Illustrator's limit). const MAX_PARA: f64 = 1296.0; +/// `text.setFormat`'s `bullet` values and the bullets they pick. +const BULLETS: [(&str, vectorcraft_doc::text::Bullet); 4] = [ + ("dot", vectorcraft_doc::text::Bullet::Dot), + ("dash", vectorcraft_doc::text::Bullet::Dash), + ("circle", vectorcraft_doc::text::Bullet::Circle), + ("square", vectorcraft_doc::text::Bullet::Square), +]; + /// 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]>> { @@ -312,6 +320,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "firstLineIndent", "spaceBefore", "spaceAfter", + "list", + "bullet", "hyphenate", "wordsLongerThan", "afterFirst", @@ -410,6 +420,12 @@ 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"])?; + 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 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")?); let single_word = match p.get("singleWordJustification") { @@ -506,6 +522,17 @@ 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); + 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 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 { + para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; + para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; + } + } if let Some(v) = flag("hyphenate") { para.hyphenate = v; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 45532031c..4e4d837a5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -715,7 +715,8 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { if dx != 0.0 && g.rtl { return Err(bad(C, "a right-to-left character moves only up and down (dy)")); } - let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.byte + h.len == a); + // A list marker's glyphs (length 0) stand for no character. + let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.len > 0 && h.byte + h.len == a); let pr = prev.map(|h| (h.byte, h.byte + h.len)); let em = glyph_em(&t, g).ok_or_else(|| bad(C, "the character's size can't be measured"))?; let em_prev = match prev { @@ -771,7 +772,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { let mut trial = t.clone(); touch(&mut trial, None, None, to - shift); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - l.glyphs.iter().find(|h| h.byte == a).map(|h| h.xf * Point::ZERO) + l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map(|h| h.xf * Point::ZERO) }; let per_step = match (pen_at(from + step), pen_at(from + 2.0 * step)) { (Some(p), Some(q)) => linear(t.xf, q - p).hypot() / step.abs(), @@ -788,7 +789,9 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // out alone, in point type of the same orientation, before and after the change. let grown = { let mut alone = TextObject { kind: TextKind::Point, runs: edit::slice_runs(&t.runs, a, b), paras: Vec::new(), ..t.clone() }; - let advance = |o: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().map(|h| h.advance).sum::(); + let advance = |o: &TextObject| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().filter(|h| h.len > 0).map(|h| h.advance).sum::() + }; let old = advance(&alone); edit::style_range(&mut alone.runs, 0, b - a, |st| change.apply(st)); (advance(&alone) - old) * lay.fit_scale @@ -796,15 +799,15 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // The character lands `moved` further on, and the character after it on its line keeps its // distance from it, wider or narrower only by the character's new scales. let goal = pen(g) + moved; - let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.line == g.line).map(|h| pen(h) + grown); + let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.len > 0 && h.line == g.line).map(|h| pen(h) + grown); // How far the character and the character after it on its line miss their places, along the // line, with the kerning `k` (before, own) added. let miss = |k: (Option, Option)| { let mut trial = t.clone(); touch(&mut trial, k.0, k.1, raise); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - let h = l.glyphs.iter().find(|h| h.byte == a)?; - let next = match (l.glyphs.iter().find(|n| n.byte == b && n.line == h.line), next_goal) { + let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0)?; + let next = match (l.glyphs.iter().find(|n| n.byte == b && n.len > 0 && n.line == h.line), next_goal) { (Some(n), Some(at)) => pen(n) - at, _ => 0.0, }; diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index cf3585f59..3f60435f3 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -168,7 +168,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { /// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's /// JSON leaves out at their defaults (the Hyphenation and Justification options, the Every-line Composer, no tab -/// stops, Auto direction…) are written out, as [`char_attrs`] does for characters. +/// stops, no list, Auto direction…) are written out, as [`char_attrs`] does for characters. pub fn para_attrs(v: &ParaStyle) -> Map { let mut m = to_attrs(v); for (k, x) in [ @@ -176,6 +176,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("justification", json!(v.justification)), ("composer", json!(v.composer)), ("tabs", json!(v.tabs)), + ("list", json!(v.list)), ("mojikumi", json!(v.mojikumi)), ("direction", json!(v.direction)), ("leading_model", json!(v.leading_model)), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 4c45df5bd..06a578509 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1441,6 +1441,92 @@ mod area_tests { assert_eq!(text(&s).para_at(1).kashida, Kashida::Stylistic); } + /// Paragraph panel › Bullets: `text.setFormat {list, bullet}` makes the paragraphs it touches a + /// bulleted list and refuses unknown values. A paragraph that joins a list with no left or + /// first-line indent gets 18 pt and -18 pt, unless the call sets an indent, and one with indents + /// keeps them. The list is saved only when set and opens with the file, Return continues it, + /// "none" turns it off, and paragraph styles set it and turn it off. + #[test] + fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { + use vectorcraft_doc::text::{Bullet, 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 indents = |s: &Session, i: usize| (text(s).para_at(i).left_indent, text(s).para_at(i).first_line_indent); + let dot = Some(ParaList::bulleted(Bullet::Dot)); + assert_eq!(list(&s, 0), None); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("list").is_none(), "no list is saved without one"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"start": 4, "end": 4, "leftIndent": 30, "firstLineIndent": 5})).unwrap(); + // The first two paragraphs: the first gets the hanging indent, the second keeps its indents. + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 4})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dot, dot, None)); + assert_eq!((indents(&s, 0), indents(&s, 1), indents(&s, 2)), ((18.0, -18.0), (30.0, 5.0), (0.0, 0.0))); + // `bullet` picks the bullet, and `list: "bullet"` keeps it; the indents stay as they are. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 0})).unwrap(); + let square = Some(ParaList::bulleted(Bullet::Square)); + assert_eq!((list(&s, 0), indents(&s, 0)), (square, (18.0, -18.0))); + for bad in [json!({"list": "numbers"}), json!({"bullet": "star"}), json!({"list": 1}), json!({"bullet": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!((list(&s, 0), list(&s, 2)), (square, None), "an unknown value changes nothing"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); + // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. + let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; + assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); + 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), (square, dot, None)); + // Return at the end of a list item starts another one. + s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 3, "insert": "\nOne more"})).unwrap(); + assert_eq!((list(&s, 1), indents(&s, 1)), (square, (18.0, -18.0))); + s.execute("edit.undo", &json!({})).unwrap(); + // "none" turns the list off and leaves the indents. + s.execute("text.setFormat", &json!({"bullet": "none", "start": 0, "end": 0})).unwrap(); + assert_eq!((list(&s, 0), indents(&s, 0)), (None, (18.0, -18.0))); + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + assert_eq!(list(&s, 1), None); + // Indents the call sets win over the hanging indent. + s.execute("text.setFormat", &json!({"list": "bullet", "leftIndent": 0, "firstLineIndent": 0, "start": 8, "end": 8})).unwrap(); + assert_eq!((list(&s, 2), indents(&s, 2)), (dot, (0.0, 0.0))); + // A style that holds a list sets it; a style captured from a paragraph without one turns it off. + s.execute("paraStyle.new", &json!({"name": "Listed", "attrs": {"list": {"bullet": "dash"}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Listed", "id": id})).unwrap(); + let dash = Some(ParaList::bulleted(Bullet::Dash)); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dash, dash, dash)); + 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.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() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let outlined = |s: &mut Session, list: bool| { + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One"})).unwrap()["id"].as_u64().unwrap(); + if list { + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); + } + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + s.doc().unwrap().doc.node(NodeId(g)).unwrap().children().unwrap().len() + }; + let plain = outlined(&mut s, false); + assert_eq!(outlined(&mut s, true), plain + 1, "one more path: the bullet"); + } + /// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and /// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their /// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They diff --git a/crates/engine/src/tests_inline.rs b/crates/engine/src/tests_inline.rs index 71affad36..6ed8533b7 100644 --- a/crates/engine/src/tests_inline.rs +++ b/crates/engine/src/tests_inline.rs @@ -167,6 +167,23 @@ fn inline_graphics_follow_their_symbol() { let _ = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); } +/// A list paragraph that starts with an inline graphic draws its bullet before the graphic, and +/// live SVG text writes the bullet. +#[test] +fn a_list_paragraph_starting_with_an_inline_graphic_keeps_its_bullet() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 10.0); + let id = text(&mut s, "ab"); + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Dot", "scale": 1})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet"})).unwrap(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)); + assert_eq!(lay.markers.len(), 1); + let bullet = &lay.glyphs[lay.markers[0].glyphs.start]; + assert!(bullet.origin.x < lay.inlines[0].bounds.x0, "the bullet comes before the graphic"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(svg.contains('\u{2022}'), "{svg}"); +} + #[test] fn saved_documents_keep_inline_graphics() { let mut s = session(); diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 8a0a8edb1..b458f55fd 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -238,6 +238,29 @@ fn touch_type_moves_one_character_and_leaves_the_others_where_they_are() { assert!(s.execute("text.touchType", &json!({"id": id.0, "start": (1u64 << 32) + 1, "dy": 1})).is_err(), "no character there"); } +/// Touch Type on the first character of a list item moves that character and leaves the bullet +/// where it is. The bullet stands for no character, so the first character still moves only up +/// and down. +#[test] +fn touch_type_moves_the_first_character_of_a_list_item_and_not_its_bullet() { + let mut s = session(); + let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet"})).unwrap(); + let pens = |s: &Session| { + let t = obj(s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let bullet = lay.glyphs.iter().find(|g| g.len == 0).map(|g| t.xf * (g.xf * Point::ZERO)).unwrap(); + (bullet, lay.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, t.xf * (g.xf * Point::ZERO))).collect::>()) + }; + let (bullet, before) = pens(&s); + assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dx": 5})).is_err(), "no character before it to kern"); + s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 3})).unwrap(); + let (bullet_after, after) = pens(&s); + assert!(near(moved_by(&before, &after, 0), (0.0, -3.0)), "the T moves 3 pt up: {:?}", moved_by(&before, &after, 0)); + assert!(near(moved_by(&before, &after, 1), (0.0, 0.0))); + assert!((bullet_after - bullet).hypot() < 1e-6, "the bullet stays: {bullet:?} {bullet_after:?}"); +} + /// Raising, scaling or rotating a character leaves it and the characters after it where they /// were along the line, also where the font kerns it with its neighbors (the T and the y). #[test] diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 4c4a45ce5..3f282914e 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, 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) 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 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)"; @@ -1396,7 +1396,7 @@ fn lost_attributes(t: &TextObject) -> bool { || !s.features.is_empty() || !s.font_axes.is_empty() }; - let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty(); + let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty() || p.list.is_some(); t.runs.iter().any(|r| run_lost(&r.style)) || t.paragraph_styles().iter().any(para_lost) || (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default()) diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index d2e9d8041..0b2d9dace 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -997,6 +997,21 @@ 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. +#[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(); + } + 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] fn type_reads_back_as_text() { use vectorcraft_doc::{Justify, TextKind}; diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index e0be4bf30..e2dbbd338 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -2005,6 +2005,11 @@ 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. + if !lay.markers.is_empty() { + self.warn("type on a path with bullets is outlined to keep its look"); + return self.text_outlines(n, t); + } return self.text_on_path(n, t, path, *start); } self.note_fonts(t, &lay); @@ -2032,7 +2037,13 @@ impl Writer<'_> { let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); // The element's user space is text space. let space = (lay.bounds, Affine::IDENTITY); - let base = TextBase { props: self.char_props(&drawn.first_style(), space), space, anchored: uniform.is_some() }; + let mut base_props = self.char_props(&drawn.first_style(), space); + // A `` cannot undo the decoration of the `` around it: with bullets, which + // have none, each run's `` carries its own. + if !lay.markers.is_empty() { + base_props.retain(|(k, _)| *k != "text-decoration"); + } + let base = TextBase { props: base_props, space, anchored: uniform.is_some() }; let mut props = base.props.clone(); if let Some((a, _)) = uniform { props.push(("text-anchor", a.into())); @@ -2116,7 +2127,14 @@ impl Writer<'_> { // The baseline shift the current text position carries. let mut shift = 0.0; for seg in line { - let Some(st) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let Some(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let marker_style; + let st = if seg.marker { + marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style); + &marker_style + } else { + run_style + }; let mut attrs = String::new(); if place { let (x, y) = if first { start } else { (seg.x, seg.y) }; @@ -2164,7 +2182,7 @@ impl Writer<'_> { let db = FontDb::global(); let plain = t.plain_text(); let mut face: Option> = None; - for g in &lay.glyphs { + for (i, g) in lay.glyphs.iter().enumerate() { let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue }; // A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the // viewer. @@ -2188,8 +2206,9 @@ impl Writer<'_> { self.fonts.len() - 1 }); let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue }; - // As written: a hyphen where a line breaks, capitals for All Caps, the style's Digits. - let src = if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; + // As written: a list marker's characters, a hyphen where a line breaks, capitals for All + // Caps, the style's Digits. + let src = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }); for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}').map(|c| st.digits.map(c)) { if st.all_caps { chars.extend(c.to_uppercase()); @@ -2407,6 +2426,9 @@ struct Segment { /// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to /// (`textLength`); 1 for other segments. scale: f64, + /// A list marker's characters and tab, written in + /// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style. + marker: bool, } /// How far a reader may draw a character from where the layout draws it before the character is @@ -2443,18 +2465,24 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let mut tail = None; let mut segs: Vec = Vec::new(); let mut cluster = None; - let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; + // A list marker's glyphs write its characters and then a tab (glyph `i` of the layout). + let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| { + lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }) + }; let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { - while let Some((_, rest)) = glyphs.split_last().filter(|(g, _)| source(g).chars().all(|c| c.is_whitespace() && c != '\t')) { + while let Some((_, rest)) = + glyphs.split_last().filter(|(g, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t')) + { glyphs = rest; } } let mut after_inline = false; - for g in glyphs { + for (k, g) in glyphs.iter().enumerate() { let Some(run) = t.runs.get(g.run) else { continue }; - if run.inline.is_some() { + // A list marker before an inline graphic is in the graphic's run, and is text. + if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() { // An inline graphic (written as art): the text after it is placed anew. if let Some(last) = segs.last_mut() { last.brk = true; @@ -2473,7 +2501,9 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - } continue; } - cluster = Some(g.byte); + // A glyph that stands for no character (a list marker before the paragraph's first + // character) starts no cluster. + cluster = (g.len > 0).then_some(g.byte); let (advance, x) = match set { Some(&(advance, offset)) => { // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. @@ -2482,7 +2512,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - } None => (g.advance, g.origin.x), }; - let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); + let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); let piece = if run.style.all_caps { src.to_uppercase() } else { src }; let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); // A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width. @@ -2492,18 +2522,23 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let moved = reader.is_some() && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); pen = x + advance; + // A list marker is a segment of its own, and so is the text after it. In horizontally + // scaled type a tab is a segment apart from the characters before it, so a reader + // stretching those characters to their width (`textLength`) stretches them alone. + let marker = lay.marker_of(line.glyph_start + k).is_some(); + let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9; match segs.last_mut() { - Some(last) if last.run == g.run && !last.brk && !fresh && !moved => { + Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => { last.text.push_str(&piece); last.advance += advance; last.brk = brk; } last => { if let Some(last) = last { - last.brk |= moved; + last.brk |= moved || last.marker != marker; end_segment(last, tail, t, lay, &mut unkerned); } - segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale }); + segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker }); } } tail = set.map(|_| (g.byte, advance)); diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index c186347bc..c7e32d619 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -254,12 +254,13 @@ fn canvas_glyph_positions(d: &Document) -> (Vec, Vec = vec![]; let mut faces: Vec> = vec![]; let mut last = None; - for g in &lay.glyphs { - let text = if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] }; + 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] }); if text.trim().is_empty() || (g.len > 0 && last == Some(g.byte)) { continue; } - last = Some(g.byte); + // A list marker's glyphs (length 0) start no cluster. + last = (g.len > 0).then_some(g.byte); if let Some(f) = db.face_by_id(g.font_id).filter(|f| !faces.iter().any(|o| o.id() == f.id())) { faces.push(f); } @@ -283,6 +284,95 @@ fn assert_drawn_in_place(d: &Document) { } } +/// Bullets: live text writes each list paragraph's bullet as its character before the paragraph's +/// text, not as a hyphen, and a viewer draws the bullet and the text where the canvas draws them. +#[test] +fn bullets_are_written_as_their_characters_where_they_are_drawn() { + use vectorcraft_doc::text::{Bullet, ParaList}; + for (bullet, character) in [(Bullet::Dot, "\u{2022}"), (Bullet::Dash, "\u{2013}"), (Bullet::Square, "\u{25AA}")] { + let mut t = TextObject::point(Point::new(20.0, 20.0), "alpha beta gamma delta epsilon\nzeta", 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::bulleted(bullet)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + 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(); + let svg = export(&d, &ExportOptions::default()); + assert_eq!(svg.matches(character).count(), 2, "one bullet before each paragraph: {svg}"); + assert!(svg.find(character) < svg.find("alpha") && !svg.contains(">-<") && !svg.contains("-alpha"), "{svg}"); + if bullet == Bullet::Dot { + assert_drawn_in_place(&d); + } + } + // A raised and rotated first character: the bullet is written on the baseline, upright. + let raised = CharStyle { baseline_shift: 4.0, rotation: 20.0, ..style(12.0) }; + let mut t = TextObject::point(Point::new(20.0, 20.0), "", style(12.0)); + t.runs = vec![run("R", raised), run("aised", style(12.0))]; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + 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(); + let svg = export(&d, &ExportOptions::default()); + // Each `` start tag with the text after it. + let pieces: Vec<(&str, &str)> = svg.split("')).collect(); + let (tag, _) = pieces.iter().find(|(_, text)| text.starts_with('\u{2022}')).expect("the bullet's tspan"); + assert!(!tag.contains("dy=") && !tag.contains("rotate="), "{svg}"); + assert!( + pieces.iter().any(|(tag, text)| text.starts_with('R') && tag.contains("dy=") && tag.contains("rotate=")), + "the R keeps its shift and rotation: {svg}" + ); + // Horizontally scaled type: a viewer stretches the bullet to the bullet's own width, not to the + // gap after it. + let mut t = TextObject::point(Point::new(20.0, 20.0), "Narrow", CharStyle { h_scale: 50.0, ..style(12.0) }); + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let width = lay.glyphs[lay.markers[0].glyphs.start].advance; + 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(); + let svg = export(&d, &ExportOptions::default()); + let pieces: Vec<(&str, &str)> = svg.split("')).collect(); + let (tag, text) = pieces.iter().find(|(_, text)| text.starts_with('\u{2022}')).expect("the bullet's tspan"); + let length: f64 = tag.split("textLength=\"").nth(1).and_then(|r| r.split('"').next()).and_then(|v| v.parse().ok()).expect("a textLength"); + assert!(text.starts_with("\u{2022}<") && (length - width).abs() < 0.01, "the bullet alone, {width} wide: {svg}"); + // Underlined text: the underline is the text's, not the bullet's. + let mut t = TextObject::point(Point::new(20.0, 20.0), "Underlined", CharStyle { underline: true, ..style(12.0) }); + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + 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(); + let svg = export(&d, &ExportOptions::default()); + let text_tag = svg.split("').next()).expect("a "); + let pieces: Vec<(&str, &str)> = svg.split("')).collect(); + let (bullet_tag, _) = pieces.iter().find(|(_, text)| text.starts_with('\u{2022}')).expect("the bullet's tspan"); + assert!(!text_tag.contains("text-decoration") && !bullet_tag.contains("text-decoration"), "{svg}"); + assert!(pieces.iter().any(|(tag, text)| text.starts_with("Underlined") && tag.contains("text-decoration=\"underline\"")), "{svg}"); + // Type on a path with a bullet is written as outlines, with a warning: a `` holds the + // characters of the text only. + for list in [None, Some(ParaList::bulleted(Bullet::Dot))] { + 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 = list; + 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(); + let out = vectorcraft_svg::export_full(&d, &ExportOptions::default(), None); + let warned = out.warnings.iter().any(|w| w.contains("bullets")); + assert_eq!((out.svg.contains(" { let mut v = Vec::with_capacity(r.len()); let levels = bidi.map_or(&[][..], |b| &b.levels); shape_range(self.db, self.text, r, &self.runs, &self.inlines, &self.opts.features, levels, &mut v); + self.finish_shaping(&mut v, |g| self.style_at(g.byte)); + v + } + + /// Set shaped glyphs `v` as the type sets them: in vertical type, tate-chu-yoko (by the size + /// and Tate-chu-yoko of each glyph's `style`) and the cells of upright glyphs; with System + /// Layout on or Fractional Widths off, whole-point advances. + fn finish_shaping<'s>(&self, v: &mut [SGlyph], style: impl Fn(&SGlyph) -> &'s CharStyle) { if self.vertical { - tate_chu_yoko(&mut v, |g| { - let st = self.style_at(g.byte); + tate_chu_yoko(v, |g| { + let st = style(g); (st.size, st.tate_chu_yoko) }); // An upright glyph advances down the column by its vertical advance (the font's vertical @@ -98,9 +106,50 @@ impl Ctx<'_> { // System Layout or Fractional Widths off: whole-point advances, after tate-chu-yoko and the // upright cell have set the widths the line breaker will use. A zero advance stays zero. if self.system_layout || !self.fractional_widths { - whole_character_widths(&mut v); + whole_character_widths(v); } - v + } + + /// 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 + /// [`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 + /// 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(); + 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); + for g in &mut marker { + g.byte = pr.start; + g.len = 0; + g.run = run; + } + self.finish_shaping(&mut marker, |_| &st); + let Some(last) = marker.last() else { return 0 }; + let mut tab = last.clone(); + tab.ch = '\t'; + tab.gid = 0; + tab.dx = 0.0; + tab.dy = 0.0; + tab.tcy = None; + tab.vpal = None; + let pen = para.left_indent + para.first_line_indent + marker.iter().map(|g| g.adv).sum::(); + let to_indent = para.left_indent - pen; + let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < para.left_indent - EPS); + tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg).0 }; + marker.push(tab); + let n = marker.len(); + sg.splice(0..0, marker); + n } fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { @@ -281,10 +330,20 @@ pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usi let glyphs = &layout.glyphs; let mut i = 0; while let Some(g) = glyphs.get(i) { - // The stretch: the glyphs of this run on this line, in a row. - let end = glyphs.iter().skip(i).position(|h| h.run != g.run || h.line != g.line).map_or(glyphs.len(), |n| i + n); + // The stretch: the glyphs of this run on this line, in a row. A list marker has no + // underline or strikethrough. + let marker = layout.marker_of(i).is_some(); + let end = glyphs + .iter() + .enumerate() + .skip(i) + .position(|(k, h)| h.run != g.run || h.line != g.line || layout.marker_of(k).is_some() != marker) + .map_or(glyphs.len(), |n| i + n); let stretch = glyphs.get(i..end).unwrap_or_default(); i = end.max(i + 1); + if marker { + continue; + } let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue }; if !(style.underline || style.strikethrough) { continue; @@ -885,13 +944,14 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, re std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::take(&mut cx.out.inlines), + std::mem::take(&mut cx.out.markers), std::mem::replace(&mut cx.out.overflow, false), ); flow(cx, paras, t, Some(regions)); if laid_out(&cx.out) < before { // Text no longer fits (lines got narrower, or flow around a wrap object): undo the // pass and try a smaller step. - (cx.out.glyphs, cx.out.lines, cx.out.inlines, cx.out.overflow) = prev; + (cx.out.glyphs, cx.out.lines, cx.out.inlines, cx.out.markers, cx.out.overflow) = prev; for (r, (shift, gap)) in regions.iter_mut().zip(saved) { r.shift = shift; r.gap = gap; @@ -1318,11 +1378,12 @@ fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, re } /// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the -/// tab origin: tabs take what [`tab_advance`] gives them, not the width of their glyph. +/// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their +/// glyph. The tab after a list marker (length 0) keeps its own width. fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { let mut x = x_start; for (j, g) in sg.iter().enumerate().take(to).skip(from) { - x += if g.ch == '\t' { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv }; + x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv }; } x - x_start } @@ -1367,9 +1428,12 @@ fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, /// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces /// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it. +/// A list marker at `i` (glyphs of length 0) counts whole, with the word after it. fn word_width(g: &[SGlyph], i: usize) -> f64 { - let mut w = 0.0; - for gl in g.get(i..).unwrap_or_default() { + let rest = g.get(i..).unwrap_or_default(); + let marker = rest.iter().take_while(|gl| gl.len == 0).count(); + let mut w: f64 = rest.iter().take(marker).map(|gl| gl.adv).sum(); + for gl in rest.iter().skip(marker) { if gl.is_space() { break; } @@ -1407,11 +1471,14 @@ fn break_line( } let mut x = 0.0; let mut last_break: Option<(usize, bool)> = None; + // A list marker (length 0) is a cluster of its own; the line holds it and at least the first + // cluster after it. + let first = i + g.get(i..).unwrap_or_default().iter().take_while(|h| h.len == 0).count(); let mut j = i; while j < g.len() { let gl = &g[j]; let mut cluster_end = j + 1; - while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte) { + while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte && (next.len == 0) == (gl.len == 0)) { cluster_end += 1; } // No Break: the marked characters stay on one line, including across a space inside the run. @@ -1432,7 +1499,7 @@ fn break_line( } else { g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() }; - if j > i && !gl.is_space() && x + cluster_width > width + EPS { + if j > first && !gl.is_space() && x + cluster_width > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { let mut end = cluster_end; @@ -1445,7 +1512,7 @@ fn break_line( } x += cluster_width; let last = &g[cluster_end - 1]; - if last.break_after() && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) { + if last.break_after() && last.len > 0 && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) { if last.is_soft_hyphen() { if x + hyphen_glyph(last).adv <= width + EPS { last_break = Some((cluster_end, true)); @@ -1876,7 +1943,8 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pe let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). - let has_tab = sg.iter().any(|g| g.ch == '\t'); + // The tab after a list marker always starts the first line and has a fixed width. + let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab { return None; } @@ -1895,6 +1963,8 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pe let mut sim = pen.clone(); let mut widths = vec![]; let mut acc = 0.0; + // A line's width and placement depend on the sum of its indents alone, which the mirrored + // indents of a right-to-left list ([`line_indents`]) keep. while widths.len() < sg.len() { let first = widths.is_empty(); let ind_l = para.left_indent + if first { para.first_line_indent } else { 0.0 }; @@ -1934,6 +2004,21 @@ 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))) } +/// 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 { + rtl && !vertical && para.list.is_some() +} + +/// The indents at the left and at the right of a line of paragraph `para` (its first line when +/// `first`). With `mirror` ([`mirrors_indents`]) the left and first-line indents are measured from +/// the right and the right indent from the left: the bullet of a right-to-left list hangs at the +/// right, and the lines it wraps to line up with the text after the bullet. +fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { + let start = para.left_indent + if first { para.first_line_indent } else { 0.0 }; + if mirror { (para.right_indent, start) } else { (start, para.right_indent) } +} + /// Flow paragraphs `paras` (byte ranges) of `t`, each with its own paragraph attributes /// (alignment, indents, spacing, direction, mojikumi, leading model…). fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Option<&[Region]>) { @@ -1961,12 +2046,24 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); // Japanese composition: consecutive punctuation shares one half-em space. - let compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; + let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; // …and a quarter em between Japanese and Latin text. - let wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; + let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; // 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 flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + 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); + if marker > 0 { + flex.splice(0..0, vec![Flex::default(); marker]); + if !compressed.is_empty() { + compressed.splice(0..0, vec![false; marker]); + } + if !wakan.is_empty() { + wakan.splice(0..0, vec![0.0; marker]); + } + } // …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size. let auto_leading = |st: &CharStyle| st.leading.unwrap_or_else(|| para.justification.auto_leading_for(st.size)); if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { @@ -1994,10 +2091,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let mut hyphen_run = 0; let mut li_para = 0; let mut i = 0; + let mirror = mirrors_indents(para, rtl, cx.vertical); loop { let est = if i < n { Metrics::of(&sg[i]) } else { pm }; let first_line = li_para == 0; - let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 }; + let (ind_l, ind_r) = line_indents(para, first_line, mirror); // Measured before Mojikumi narrows an opening bracket, as the composer measures it. let word = word_width(&sg, i); // Mojikumi: an opening bracket starting a line is set flush with the line's start (the @@ -2016,11 +2114,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // next row/column if it no longer fits). let (baseline, x0, x1, end, hyph, m) = loop { let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent, word) else { + let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; break 'paras; }; - let width = x1 - x0 - ind_l - para.right_indent; + let width = x1 - x0 - ind_l - ind_r; let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone)); let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { Some(&(e, h)) if e > i => (e, h), @@ -2050,7 +2148,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio break (baseline, x0, x1, end, hyph, m); }; hyphen_run = if hyph && end > i { hyphen_run + 1 } else { 0 }; - let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - para.right_indent) } else { (x0, x1) }; + let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - ind_r) } else { (x0, x1) }; let width = ax1 - ax0; pen.pending = 0.0; let last_of_para = end >= n; @@ -2075,10 +2173,20 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs. let share: Option f64> = (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); - // The glyphs before `stop` in visual order, each with the advance it is set with. + // The glyphs before `stop` in visual order, each with the advance it is set with. A list + // marker does not hang: it is counted with no width. + let set = |j: usize, g: &SGlyph| { + if j < marker { + 0.0 + } else if j + 1 == trimmed { + g.adv - end_trim + } else { + g.adv + } + }; let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { let at: Vec = order.iter().map(|&offset| i + offset).filter(|&j| j < stop).collect(); - let glyphs = at.iter().filter_map(|&j| sg.get(j).map(|g| (g, if j + 1 == trimmed { g.adv - end_trim } else { g.adv }))).collect(); + let glyphs = at.iter().filter_map(|&j| sg.get(j).map(|g| (g, set(j, g)))).collect(); (at, glyphs) }; // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: @@ -2138,6 +2246,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } else { (0.0, 0.0, vec![], vec![]) }; + // Justification adds nothing to the list marker's glyphs. + let mut held = held; + held.extend(i..marker.min(body)); // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph // scaling of a `hung` one. let line_flex = |j: usize| { @@ -2164,7 +2275,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let justify = justify && regions.is_some(); let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); let mut fit = LineFit::default(); - let spaces = sg[i..body].iter().filter(|g| g.is_space()).count(); + let spaces = (i..body).filter(|&j| j >= marker && sg[j].is_space()).count(); // A gap that justification adds between two glyphs goes into the advance of the one // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a // right-to-left line comes before it in the text. @@ -2207,7 +2318,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // Word spacing, letter spacing and glyph scaling within their ranges, then the // word spaces alone; composed lines may shrink word spaces (never below zero). let line: Vec = (i..body).map(line_flex).collect(); - let floor = sg[i..body].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min); + let floor = (i..body).filter(|&j| j >= marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); fit = fit_line(&line, room - w, spaces, floor); } else if room > w { // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. @@ -2228,8 +2339,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } else if regions.is_none() { match align { 0 => ind_l - opt_l, - 1 => (ind_l - opt_l - para.right_indent + opt_r - w) * 0.5, - _ => -para.right_indent + opt_r - w, + 1 => (ind_l - opt_l - ind_r + opt_r - w) * 0.5, + _ => -ind_r + opt_r - w, } } else { match align { @@ -2253,6 +2364,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let leading_space_width: f64 = order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum(); let mut x = start_x - leading_space_width; + // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. + let mut marker_glyphs: Option> = None; + let mut marker_tab = 0; for (oi, &offset) in order.iter().enumerate() { let j = i + offset; let Some(g) = sg.get(j) else { continue }; @@ -2264,7 +2378,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let mut scale = 1.0; let mut leader = ""; if g.ch == '\t' { - (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); + // The tab after a list marker keeps the width `prepend_marker` gave it. + if g.len > 0 { + (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); + } } else if j < body { let f = line_flex(j); if is_word_space(g) { @@ -2296,6 +2413,13 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio adv += kashida.unwrap_or(0.0); let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); let place = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; + if j < marker { + let at = cx.out.glyphs.len(); + marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |r: Range| r.start.min(at)..r.end.max(at + 1))); + if j + 1 == marker { + marker_tab = at; + } + } cx.emit(g, place, Point::new(x, y), 0.0, adv, li); if let Some(out) = cx.out.glyphs.last_mut() { out.scale = flex.get(j).map_or(1.0, |f| f.scale) * scale; @@ -2316,6 +2440,9 @@ 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(h) = &hyphen { // The hyphen follows the last non-space glyph. let hx = x_end; @@ -2381,11 +2508,13 @@ 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(); - for pr in 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 glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); + 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); shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs)); } let rtl = rtl.unwrap_or(false); @@ -2447,7 +2576,8 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path return (0.0, 0.0, 0); } let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share }; - let line: Vec<(&SGlyph, f64)> = sg.iter().zip(steps).map(|(g, &step)| (g, step)).collect(); + // A list marker (the glyphs of length 0) does not hang: it is counted with no width. + let line: Vec<(&SGlyph, f64)> = sg.iter().zip(steps).map(|(g, &step)| (g, if g.len == 0 { 0.0 } else { step })).collect(); let (l, r, _, hung_r) = if rtl { optical_hangs_rtl(&line, share) } else { optical_hangs(&line, share) }; let (l, r) = if ap.is_closed() { let gap = (ap.len() - avail).max(0.0); @@ -2592,6 +2722,23 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path }; // The caret's baseline moves with the type. let origin = p - dir * (g.adv * 0.5) + Vec2::new(-dir.y, dir.x) * rise; + // A list marker's glyphs (the only glyphs here with no length) are in a row at the byte of + // their paragraph's start. + if g.len == 0 { + let at = cx.out.glyphs.len(); + let starts_paragraph = |b: usize| paras.iter().position(|p| p.start == b); + 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(m) = cx.out.markers.last_mut().filter(|m| m.glyphs.end == at + 1 && g.ch == '\t') { + m.tab = at; + } + } cx.emit(g, pre, origin, angle, g.adv, 0); x += step; } @@ -2689,9 +2836,10 @@ fn visual_order(bidi: Option<&BidiInfo<'_>>, paragraph_start: usize, line: Range return vec![]; } let levels = bidi.reordered_levels(para, line.start.saturating_sub(paragraph_start)..line.end.saturating_sub(paragraph_start)); + // A list marker (length 0) is a cluster of its own. let mut clusters: Vec> = Vec::new(); for (i, g) in glyphs.iter().enumerate() { - if i > 0 && glyphs.get(i - 1).is_some_and(|p| p.byte == g.byte) { + if i > 0 && glyphs.get(i - 1).is_some_and(|p| p.byte == g.byte && (p.len == 0) == (g.len == 0)) { if let Some(c) = clusters.last_mut() { c.end = i + 1; } @@ -2699,12 +2847,25 @@ fn visual_order(bidi: Option<&BidiInfo<'_>>, paragraph_start: usize, line: Range clusters.push(i..i + 1); } } + // A list marker is at the paragraph's level, and at a right-to-left one its tab is drawn + // before (left of) its characters. let cluster_levels: Vec<_> = clusters .iter() .filter_map(|c| glyphs.get(c.start)) - .map(|g| levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level)) + .map(|g| if g.len == 0 { g.level } else { levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level) }) .collect(); - unicode_bidi::BidiInfo::reorder_visual(&cluster_levels).into_iter().filter_map(|i| clusters.get(i)).flat_map(|c| c.clone()).collect() + let reversed = |i: usize| glyphs.get(clusters.get(i).map_or(0, |c| c.start)).is_some_and(|g| g.len == 0 && g.level.is_rtl()); + unicode_bidi::BidiInfo::reorder_visual(&cluster_levels) + .into_iter() + .filter_map(|i| clusters.get(i).map(|c| (c.clone(), reversed(i)))) + .flat_map(|(c, rev)| { + let mut v: Vec = c.collect(); + if rev { + v.reverse(); + } + v + }) + .collect() } /// Ruby (furigana): lays each reading out over its base text in line space, grows the line's @@ -2749,11 +2910,11 @@ fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { continue; } // The base glyphs of the span, by line; a glyph's cluster is its byte range (`len`, or - // one byte for generated glyphs). + // one byte for generated glyphs). A list marker is not part of the base. let mut by_line: BTreeMap> = BTreeMap::new(); for (i, g) in cx.out.glyphs.iter().enumerate() { let b = g.byte.saturating_add(g.len.max(1)); - if g.byte < end && b > start { + if g.byte < end && b > start && cx.out.marker_of(i).is_none() { by_line.entry(g.line).or_default().push(i); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 020f6df16..eb221c817 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -199,6 +199,21 @@ pub struct InlineGlyph { pub bounds: Rect, } +/// A list marker (Paragraph panel › Bullets) the layout drew before the first line of a list +/// paragraph: glyphs of [`TextLayout::glyphs`] that stand for no characters of the text (`len` +/// 0, at the paragraph's first byte, in the run of its first character). +#[derive(Clone, Debug, PartialEq)] +pub struct ListMarker { + /// The marker's glyphs, in the order they are drawn: those of its characters and the tab that + /// takes the text to the next tab stop. + pub glyphs: std::ops::Range, + /// The tab's glyph, which draws nothing: the last of [`Self::glyphs`], or the first in a + /// right-to-left paragraph. + pub tab: usize, + /// The marker's characters ("•"). + pub text: String, +} + #[derive(Clone, Debug)] pub struct TextLayout { /// Lines retain inline/block coordinates; glyph geometry is in physical text space. @@ -226,6 +241,8 @@ pub struct TextLayout { pub fit_scale: f64, /// Inline graphics, in text order. pub inlines: Vec, + /// List markers, in text order. + pub markers: Vec, } impl Default for TextLayout { @@ -242,11 +259,31 @@ impl Default for TextLayout { frames: Vec::new(), fit_scale: 1.0, inlines: Vec::new(), + markers: Vec::new(), } } } impl TextLayout { + /// The list marker that glyph `i` of [`Self::glyphs`] belongs to, if any. + pub fn marker_of(&self, i: usize) -> Option<&ListMarker> { + let at = self.markers.partition_point(|m| m.glyphs.end <= i); + self.markers.get(at).filter(|m| m.glyphs.contains(&i)) + } + /// The text glyph `i` of [`Self::glyphs`] stands for when it belongs to a list marker: a tab + /// for its tab, the marker's characters for the first of its other glyphs, nothing for the + /// rest. + pub fn marker_text(&self, i: usize) -> Option<&str> { + let m = self.marker_of(i)?; + let first = if m.tab == m.glyphs.start { m.glyphs.start + 1 } else { m.glyphs.start }; + Some(if i == m.tab { + "\t" + } else if i == first { + &m.text + } else { + "" + }) + } /// Convert physical text coordinates to inline/block coordinates. pub fn logical_point(&self, p: Point) -> Point { if self.vertical { self.line_xf.inverse() * p } else { p } @@ -321,12 +358,13 @@ fn dir(angle: f64) -> Vec2 { /// The left and right x of the cluster of `glyphs[i]` (glyphs of one line of horizontal type /// that is not on a path): the glyphs next to it with its byte offset and length, and the -/// tatweels of a kashida after it (length 0, the same byte offset). None for vertical type and -/// type on a path. +/// tatweels of a kashida after it (length 0 and no advance, the same byte offset). A list marker +/// next to it (length 0 and an advance, the same byte offset) is not part of it. None for +/// vertical type and type on a path. fn cluster_extent(layout: &TextLayout, glyphs: &[PositionedGlyph], i: usize) -> Option<(f64, f64)> { let g = glyphs.get(i).filter(|_| !layout.vertical && !layout.on_path)?; - let same = |o: &PositionedGlyph| o.byte == g.byte && (o.len == g.len || o.len == 0); - let start = glyphs.get(..i)?.iter().rposition(|o| !same(o)).map_or(0, |p| p + 1); + let same = |o: &PositionedGlyph| o.byte == g.byte && (o.len == g.len || (o.len == 0 && o.advance == 0.0)); + let start = glyphs.get(..i)?.iter().rposition(|o| !(o.byte == g.byte && o.len == g.len)).map_or(0, |p| p + 1); let end = glyphs.get(i..)?.iter().position(|o| !same(o)).map_or(glyphs.len(), |p| i + p); let span = glyphs.get(start..end)?; let left = span.iter().map(|o| o.origin.x).fold(f64::INFINITY, f64::min); @@ -414,14 +452,15 @@ pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; // A cluster drawn with several adjacent glyphs (a Devanagari pre-base matra and its consonant, // a consonant and its reph) has one caret stop at each end of its glyphs taken together. The - // tatweels of a kashida have no source length and come after the glyphs of the letter after - // the kashida in the text; they belong to that letter's cluster. + // tatweels of a kashida have no source length or advance and come after the glyphs of the + // letter after the kashida in the text; they belong to that letter's cluster. A list marker's + // glyphs (no source length, an advance) are a cluster of their own. let mut clusters: Vec<(f64, f64, &PositionedGlyph)> = Vec::with_capacity(glyphs.len()); for g in glyphs { let left = layout.logical_point(g.origin).x; let right = left + g.advance; match clusters.last_mut() { - Some((l, r, c)) if c.byte == g.byte && (c.len == g.len || g.len == 0) => (*l, *r) = (l.min(left), r.max(right)), + Some((l, r, c)) if c.byte == g.byte && (c.len == g.len || (g.len == 0 && g.advance == 0.0)) => (*l, *r) = (l.min(left), r.max(right)), _ => clusters.push((left, right, g)), } } @@ -491,7 +530,8 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 return out; } if layout.on_path { - for g in layout.glyphs.iter().filter(|g| g.byte >= a && g.byte < b) { + // A list marker's glyphs stand for no characters. + for g in layout.glyphs.iter().filter(|g| g.byte >= a && g.byte < b && g.len > 0) { let li = &layout.lines[0]; let d = dir(g.angle); let up = Vec2::new(d.y, -d.x); @@ -501,7 +541,9 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 return out; } if layout.glyphs.iter().any(|g| g.rtl) { - for g in layout.glyphs.iter().filter(|g| g.byte < b && g.byte + g.len > a) { + // A list marker's glyphs stand for no characters. + let glyphs = layout.glyphs.iter().enumerate().filter(|(i, g)| g.byte < b && g.byte + g.len > a && layout.marker_of(*i).is_none()); + for (_, g) in glyphs { let Some(l) = layout.lines.get(g.line) else { continue }; let p = layout.logical_point(g.origin); let (x0, x1) = (p.x, p.x + g.advance); @@ -521,7 +563,10 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 if l.start > b || l.end < a || (l.end == a && l.start < a && layout.lines.get(li + 1).is_some_and(|n| n.start == l.end)) { continue; } - let xa = if a <= l.start { l.x0.min(x_in_line(layout, li, l.start)) } else { x_in_line(layout, li, a) }; + // A list marker before the line's first character is not part of the selection. + let next = layout.markers.partition_point(|m| m.glyphs.start < l.glyph_start); + let marked = layout.markers.get(next).is_some_and(|m| m.glyphs.start < l.glyph_end); + let xa = if a <= l.start && !marked { l.x0.min(x_in_line(layout, li, l.start)) } else { x_in_line(layout, li, a.max(l.start)) }; let mut xb = x_in_line(layout, li, b.min(l.end)); if b > l.end { // The selection continues past the line: include the line break / trailing space. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 73cdef414..f9d96deed 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -637,6 +637,339 @@ fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() { assert!(short.last().is_some_and(|&x| x < 120.0)); } +/// Bullets: a list paragraph's first line starts with its bullet, glyphs that stand for no +/// character, in the run and size of the paragraph's first character. After the bullet the text +/// starts at the left indent, and the lines it wraps to start there too. The caret before the +/// first character stands at the text, a click on the bullet puts it there, an underline leaves +/// the bullet out, and a paragraph without text has no bullet. +#[test] +fn a_list_paragraph_starts_with_its_bullet_and_its_text_at_the_left_indent() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let bulleted = |text: &str, size: f64| { + let mut t = area(text, style(size), 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; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t + }; + let t = bulleted(LOREM, 12.0); + let lay = layout(db(), &t); + assert_eq!(lay.markers.len(), 1, "{:?}", lay.markers); + let m = &lay.markers[0]; + assert_eq!((m.text.as_str(), m.glyphs.start, m.glyphs.len()), ("\u{2022}", lay.lines[0].glyph_start, 2)); + let marker = &lay.glyphs[m.glyphs.clone()]; + assert!(marker.iter().all(|g| g.len == 0 && g.byte == 0 && g.run == 0 && g.line == 0), "{marker:?}"); + assert!(marker[0].origin.x.abs() < 1e-6, "the bullet starts the first line at its first-line indent: {}", marker[0].origin.x); + assert!(!marker[0].outline.elements().is_empty() && marker[1].outline.elements().is_empty(), "the bullet draws, its tab doesn't"); + assert!((marker[1].origin.x + marker[1].advance - 18.0).abs() < 1e-6, "the tab reaches the left indent"); + // The text starts at the left indent on the first line and on every line it wraps to. + assert!(lay.lines.len() > 2, "the paragraph wraps"); + for (i, line) in lay.lines.iter().enumerate() { + let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 18.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x); + } + let (top, _) = caret_position(&lay, 0); + assert!((top.x - 18.0).abs() < 1e-6, "the caret before the first character: {top:?}"); + assert_eq!(hit_byte(&lay, Point::new(marker[0].origin.x + 1.0, lay.lines[0].baseline - 3.0)), 0); + // A selection from the paragraph's start highlights the text, not the bullet. + let quads = selection_quads(&lay, 0, 5); + assert!((quads[0][0].x - 18.0).abs() < 1e-6, "the selection starts at the text: {:?}", quads[0]); + // The bullet takes the size of the paragraph's first character. + let big = layout(db(), &bulleted(LOREM, 24.0)); + assert!((big.glyphs[0].advance - 2.0 * lay.glyphs[0].advance).abs() < 1e-6, "{} {}", big.glyphs[0].advance, lay.glyphs[0].advance); + // A baseline shift or rotation of the first character leaves the bullet on the baseline. + let mut raised = bulleted("Raised", 12.0); + raised.runs[0].style.baseline_shift = 5.0; + raised.runs[0].style.rotation = 30.0; + let lay = layout(db(), &raised); + let (bullet, first) = (&lay.glyphs[0], lay.glyphs.iter().find(|g| g.len > 0).unwrap()); + assert!((bullet.xf.translation().y - lay.lines[0].baseline).abs() < 1e-6, "{:?}", bullet.xf); + assert!((bullet.xf.as_coeffs()[1]).abs() < 1e-9 && first.xf.as_coeffs()[1].abs() > 1e-3, "the bullet isn't rotated"); + // An underline starts at the text, not under the bullet. + let mut underlined = bulleted("Underlined", 12.0); + underlined.runs[0].style.underline = true; + let lay = layout(db(), &underlined); + let bars = decorations(&lay, db(), &underlined); + assert_eq!(bars.len(), 1); + assert!((bars[0].1.bounding_box().x0 - 18.0).abs() < 1e-6, "{:?}", bars[0].1.bounding_box()); + // Only the paragraphs with text have a bullet. + let lay = layout(db(), &bulleted("One\n\nThree", 12.0)); + assert_eq!(lay.markers.iter().map(|m| lay.glyphs[m.glyphs.start].byte).collect::>(), [0, 5]); +} + +/// Bullets: in vertical type the bullet is drawn the same whether or not the paragraph's first +/// characters are set tate-chu-yoko. +#[test] +fn a_vertical_list_paragraphs_bullet_is_not_set_tate_chu_yoko() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let bullet = |tcy: bool| { + let mut t = point("", style(12.0)); + t.vertical = true; + t.runs = vec![TextRun::new("12", CharStyle { tate_chu_yoko: tcy, ..style(12.0) }), TextRun::new(" vertical", style(12.0))]; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + let lay = layout(db(), &t); + let g = &lay.glyphs[lay.markers[0].glyphs.start]; + (g.advance, g.xf) + }; + let (plain, set) = (bullet(false), bullet(true)); + let same = (plain.0 - set.0).abs() < 1e-9 && plain.1.as_coeffs().iter().zip(set.1.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9); + assert!(same, "{plain:?} {set:?}"); +} + +/// Bullets: a right-to-left list paragraph starts at its right with the bullet, then the gap, then +/// the text, and the caret before its first character stands at the text. +#[test] +fn a_right_to_left_list_paragraph_starts_with_its_bullet_at_the_right() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut t = area("שלום עולם", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Auto); + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = layout(db(), &t); + let m = &lay.markers[0]; + assert_eq!(m.tab, m.glyphs.start, "the tab is drawn first, left of the bullet"); + assert_eq!((lay.marker_text(m.tab), lay.marker_text(m.glyphs.start + 1)), (Some("\t"), Some("\u{2022}"))); + let (tab, bullet) = (&lay.glyphs[m.tab], &lay.glyphs[m.glyphs.start + 1]); + assert!((bullet.origin.x + bullet.advance - 200.0).abs() < 1e-6, "the bullet ends the line at the right: {bullet:?}"); + assert!((tab.origin.x + tab.advance - bullet.origin.x).abs() < 1e-6, "the gap is right before the bullet"); + let text_right = lay.glyphs.iter().filter(|g| g.len > 0).map(|g| g.origin.x + g.advance).fold(0.0, f64::max); + assert!((text_right - tab.origin.x).abs() < 1e-6, "the text ends at the gap: {text_right} {}", tab.origin.x); + let (top, _) = caret_position(&lay, 0); + assert!((top.x - text_right).abs() < 1e-6, "the caret before the first character: {top:?}"); + // A selection across two right-to-left list items highlights their characters alone. + let mut two = area("שלום\nעולם", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Auto); + two.para.list = Some(ParaList::bulleted(Bullet::Dot)); + let lay = layout(db(), &two); + assert_eq!(lay.markers.len(), 2); + let quads = selection_quads(&lay, 0, two.plain_text().len()); + assert_eq!(quads.len(), lay.glyphs.iter().filter(|g| g.len > 0).count(), "one quad per character glyph: {quads:?}"); +} + +/// Bullets: a right-to-left list paragraph has its indents mirrored. Its bullet hangs at the right, +/// its right indent is kept at the left, 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 a left-to-right list keeps its bullet at the left. +#[test] +fn a_right_to_left_list_paragraph_mirrors_its_hanging_indent() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let hebrew = "שלום עולם ".repeat(12); + let para = |text: &str, justify: Justify, list: bool| { + let mut t = area(text.trim_end(), style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + t.para.right_indent = 5.0; + t.para.list = list.then(|| ParaList::bulleted(Bullet::Dot)); + t + }; + // The left and right ends of line `k`'s glyphs, its spaces left out. + let ends = |t: &TextObject, lay: &TextLayout, k: usize| { + let line = &lay.lines[k]; + let text = t.plain_text(); + let glyphs = lay.glyphs[line.glyph_start..line.glyph_end].iter().filter(|g| !text[g.byte..].starts_with(' ')); + let (l, r) = glyphs.fold((f64::MAX, f64::MIN), |(l, r), g| (l.min(g.origin.x), r.max(g.origin.x + g.advance))); + (l, r) + }; + let close = |(l, r): (f64, f64), (el, er): (f64, f64)| (l - el).abs() < 1e-6 && (r - er).abs() < 1e-6; + for justify in [Justify::Auto, Justify::JustifyLeft] { + let t = para(&hebrew, justify, true); + let lay = layout(db(), &t); + assert!(lay.lines.len() > 2, "{justify:?}: the paragraph wraps"); + let m = &lay.markers[0]; + let (tab, bullet) = (&lay.glyphs[m.tab], &lay.glyphs[m.glyphs.start + 1]); + assert!((bullet.origin.x + bullet.advance - 200.0).abs() < 1e-6, "{justify:?}: the bullet ends the first line at the right: {bullet:?}"); + assert!( + (tab.origin.x - 182.0).abs() < 1e-6, + "{justify:?}: the text after the bullet starts at the left indent from the right: {}", + tab.origin.x + ); + // Justify with Last Line Aligned Left sets the last line at the left. + let wrapped = if justify == Justify::JustifyLeft { 1..lay.lines.len() - 1 } else { 1..lay.lines.len() }; + for k in wrapped { + let (_, r) = ends(&t, &lay, k); + assert!((r - tab.origin.x).abs() < 1e-6, "{justify:?}: line {k}'s right edge is at {r}, the first line's text's at {}", tab.origin.x); + } + if justify == Justify::JustifyLeft { + assert!(close(ends(&t, &lay, 0), (5.0, 200.0)), "the first line: {:?}", ends(&t, &lay, 0)); + assert!(close(ends(&t, &lay, 1), (5.0, 182.0)), "a wrapped line: {:?}", ends(&t, &lay, 1)); + } + } + // Without a list a right-to-left paragraph keeps its indents: the left and first-line indents + // at the left, the right indent at the right. + let t = para(&hebrew, Justify::JustifyLeft, false); + let lay = layout(db(), &t); + assert!(lay.markers.is_empty() && lay.lines.len() > 2); + assert!(close(ends(&t, &lay, 0), (0.0, 195.0)), "the first line: {:?}", ends(&t, &lay, 0)); + assert!(close(ends(&t, &lay, 1), (18.0, 195.0)), "a wrapped line: {:?}", ends(&t, &lay, 1)); + // A left-to-right list: the bullet at the left, every line's text at the left indent. + let t = para(LOREM, Justify::JustifyLeft, true); + let lay = layout(db(), &t); + assert!(lay.lines.len() > 2); + assert!(lay.glyphs[lay.markers[0].glyphs.start].origin.x.abs() < 1e-6, "the bullet starts the first line"); + assert!(close(ends(&t, &lay, 0), (0.0, 195.0)), "the first line: {:?}", ends(&t, &lay, 0)); + assert!(close(ends(&t, &lay, 1), (18.0, 195.0)), "a wrapped line: {:?}", ends(&t, &lay, 1)); +} + +/// Bullets: area type in a frame that isn't a rectangle, centered vertically, flows again until it +/// settles, and keeps one marker for each list paragraph, at that paragraph's bullet. +#[test] +fn vertically_centered_list_paragraphs_in_a_round_frame_keep_one_bullet_each() { + use vectorcraft_doc::VerticalAlign; + use vectorcraft_doc::text::{Bullet, ParaList}; + let text = format!("{LOREM}\n{LOREM}"); + let frame = kurbo::Ellipse::new((150.0, 150.0), (150.0, 150.0), 0.0).to_path(0.1); + let mut t = area(&text, style(12.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let top = layout(db(), &t); + t.area.vertical_align = VerticalAlign::Center; + let lay = layout(db(), &t); + assert!(lay.lines[0].baseline > top.lines[0].baseline + 1.0, "the text moves down"); + let starts: Vec = lay.markers.iter().map(|m| m.glyphs.start).collect(); + assert_eq!(starts.len(), 2, "{:?}", lay.markers); + for (m, para_start) in lay.markers.iter().zip([0, LOREM.len() + 1]) { + let glyphs = &lay.glyphs[m.glyphs.clone()]; + assert!(glyphs.iter().all(|g| g.len == 0 && g.byte == para_start), "{m:?}: {glyphs:?}"); + assert!(!glyphs[0].outline.elements().is_empty(), "the bullet draws"); + } +} + +/// Bullets: in a frame narrower at the top than the bullet, its gap and the first word, the first +/// line goes down to where they fit, and every line stays inside the frame. +#[test] +fn a_list_paragraph_starts_where_its_bullet_and_first_word_fit() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut triangle = BezPath::new(); + triangle.move_to((150.0, 0.0)); + triangle.line_to((300.0, 300.0)); + triangle.line_to((0.0, 300.0)); + triangle.close_path(); + let mut t = area("Extraordinarily long words follow", style(12.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&triangle) }; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = layout(db(), &t); + assert_eq!(lay.markers.len(), 1); + for (i, line) in lay.lines.iter().enumerate() { + for g in &lay.glyphs[line.glyph_start..line.glyph_end] { + let (l, r) = (g.origin.x, g.origin.x + g.advance); + assert!(l >= line.avail.0 - 0.01 && r <= line.avail.1 + 0.01, "line {i}: {l}..{r} outside {:?}", line.avail); + } + } +} + +/// Bullets: type on a path starts with its bullet and the gap after it, the caret before its +/// first character stands at the text, and a selection of the text highlights the characters +/// alone. +#[test] +fn type_on_a_path_starts_with_its_bullet() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let mut t = point("Path", style(20.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = layout(db(), &t); + assert_eq!(lay.markers.len(), 1, "{:?}", lay.markers); + let m = &lay.markers[0]; + assert_eq!((m.glyphs.clone(), m.tab, lay.marker_text(0)), (0..2, 1, Some("\u{2022}"))); + assert!(lay.glyphs[0].origin.x.abs() < 1e-6 && !lay.glyphs[0].outline.elements().is_empty(), "{:?}", lay.glyphs[0].origin); + let first = lay.glyphs.iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 18.0).abs() < 1e-6, "the text starts at {}", first.origin.x); + let (top, _) = caret_position(&lay, 0); + assert!((top.x - 18.0).abs() < 1e-6, "the caret before the first character: {top:?}"); + let quads = selection_quads(&lay, 0, 4); + assert_eq!(quads.len(), 4, "one quad per character"); + assert!(quads.iter().all(|q| q[0].x >= 18.0 - 1e-6), "{quads:?}"); + // A right-to-left list item on a path reads from its bullet: the bullet is drawn right of the + // text, with the gap between them. + let mut rtl = point("שלום", style(20.0)); + rtl.kind = t.kind.clone(); + rtl.para = t.para.clone(); + let lay = layout(db(), &rtl); + let m = &lay.markers[0]; + let (tab, bullet) = (&lay.glyphs[m.tab], &lay.glyphs[m.glyphs.start + 1]); + let text_right = lay.glyphs.iter().filter(|g| g.len > 0).map(|g| g.origin.x + g.advance).fold(f64::MIN, f64::max); + assert!((tab.origin.x - text_right).abs() < 1e-6 && (bullet.origin.x - tab.origin.x - tab.advance).abs() < 1e-6, "{tab:?} {bullet:?}"); + assert!((tab.advance + bullet.advance - 18.0).abs() < 1e-6, "the bullet and its gap take the left indent: {} {}", tab.advance, bullet.advance); + // Optical Margin Alignment leaves a dash bullet at the start bracket, with room before it. + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1, end: None }; + t.para.list = Some(ParaList::bulleted(Bullet::Dash)); + for optical in [false, true] { + t.optical_margin = optical; + let lay = layout(db(), &t); + assert!((lay.glyphs[0].origin.x - 50.0).abs() < 1e-6, "optical {optical}: the dash starts at {}", lay.glyphs[0].origin.x); + } +} + +/// Bullets: after the bullet the text starts at an explicit tab stop that comes before the left +/// indent, at the next stop past the bullet when the bullet reaches past the left indent, and a +/// line holds the bullet and the start of its first word however narrow the frame. Justified lines +/// and Optical Margin Alignment leave the bullet and the gap after it as they are. +#[test] +fn a_list_paragraph_starts_its_text_at_the_next_tab_stop_after_the_bullet() { + use vectorcraft_doc::text::{Bullet, ParaList}; + use vectorcraft_doc::{TabAlign, TabStop}; + let text_x = |t: &TextObject| layout(db(), t).glyphs.iter().find(|g| g.byte == 0 && g.len > 0).map(|g| g.origin.x).unwrap(); + for (text, justify, optical) in + [(LOREM, Justify::JustifyLeft, false), (LOREM, Justify::Left, true), (LOREM, Justify::JustifyAll, true), ("Item", Justify::JustifyAll, false)] + { + let mut t = area(text, style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify); + t.para.list = Some(ParaList::bulleted(Bullet::Dash)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + t.optical_margin = optical; + let lay = layout(db(), &t); + let marker = &lay.glyphs[lay.markers[0].glyphs.clone()]; + assert!(marker[0].origin.x.abs() < 1e-6, "{justify:?}, optical {optical}: the dash starts at {}", marker[0].origin.x); + assert!((text_x(&t) - 18.0).abs() < 1e-6, "{justify:?}, optical {optical}: the text starts at {}", text_x(&t)); + if justify != Justify::Left && !optical { + let line = &lay.lines[0]; + let ends = lay.glyphs[line.glyph_start..line.glyph_end] + .iter() + .filter(|g| !t.plain_text()[g.byte..].starts_with(' ')) + .map(|g| g.origin.x + g.advance) + .fold(0.0, f64::max); + assert!((ends - 200.0).abs() < 1e-6, "{justify:?}: the justified first line ends at {ends}"); + } + } + // A right-aligned item ends at the frame's right edge, its bullet and gap before it. + let mut right = area("Item", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Right); + right.para.list = Some(ParaList::bulleted(Bullet::Dot)); + right.para.left_indent = 18.0; + right.para.first_line_indent = -18.0; + let lay = layout(db(), &right); + let last = lay.glyphs.last().unwrap(); + assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6, "the line ends at {}", last.origin.x + last.advance); + let marker = &lay.glyphs[lay.markers[0].glyphs.clone()]; + assert!((marker[1].origin.x + marker[1].advance - text_x(&right)).abs() < 1e-6, "the text follows the gap"); + let mut t = area("Item", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left); + t.para.list = Some(ParaList::bulleted(Bullet::Dash)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + t.para.tabs = vec![TabStop { position: 12.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + assert!((text_x(&t) - 12.0).abs() < 1e-6, "{}", text_x(&t)); + // No indents: the bullet starts the line and the text goes to the next default stop. + t.para.tabs.clear(); + t.para.left_indent = 0.0; + t.para.first_line_indent = 0.0; + assert!((text_x(&t) - 36.0).abs() < 1e-6, "{}", text_x(&t)); + // A frame with no room for a letter after the bullet: the first line still holds the bullet and + // the word's first letter. + let mut narrow = area("Pneumonoultramicroscopic", style(12.0), Rect::new(0.0, 0.0, 20.0, 400.0), Justify::Left); + narrow.para.list = Some(ParaList::bulleted(Bullet::Dot)); + narrow.para.left_indent = 18.0; + narrow.para.first_line_indent = -18.0; + let lay = layout(db(), &narrow); + let first = &lay.glyphs[lay.lines[0].glyph_start..lay.lines[0].glyph_end]; + assert!(first.iter().any(|g| g.len > 0), "the first line holds more than the bullet: {:?}", lay.lines); +} + /// 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/tests_ruby.rs b/crates/text/src/tests_ruby.rs index fa49bf13a..5e3a66b0a 100644 --- a/crates/text/src/tests_ruby.rs +++ b/crates/text/src/tests_ruby.rs @@ -68,6 +68,23 @@ fn horizontal_ruby_sits_above_the_base_and_grows_the_line() { assert!(above.y1 <= below.y0 - 1.0, "a gap between ruby and base ink: {} vs {}", above.y1, below.y0); } +/// Bullets: a reading over a list paragraph's first characters is centered over those characters, +/// not over the bullet and the gap before them. +#[test] +fn ruby_over_a_list_item_leaves_out_its_bullet() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut t = ruby_text("漢字", 6, "かんじ", 20.0); + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = layout(db(), &t); + assert_eq!(lay.markers.len(), 1); + let above = ruby_ink(&lay); + let base = union_rect(lay.glyphs.iter().filter(|g| g.len > 0)).expect("base ink"); + let (centre, base_centre) = ((above.x0 + above.x1) * 0.5, (base.x0 + base.x1) * 0.5); + assert!((centre - base_centre).abs() < 1.0, "{centre} vs {base_centre}"); +} + #[test] fn ruby_below_grows_the_descent() { let mut t = ruby_text("漢字", 6, "かんじ", 20.0); diff --git a/crates/tools/src/touchtype.rs b/crates/tools/src/touchtype.rs index c60ba2bfc..51d4079ab 100644 --- a/crates/tools/src/touchtype.rs +++ b/crates/tools/src/touchtype.rs @@ -152,10 +152,17 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option { } let m = g.xf; let (pen, ex, ey) = (m * Point::ZERO, unit(linear(m, Vec2::new(1.0, 0.0))), unit(linear(m, Vec2::new(0.0, -1.0)))); - // The corners of its glyphs' boxes in the first glyph's font units (y down). + // The corners of its glyphs' boxes in the first glyph's font units (y down). A list marker's + // glyphs (length 0) stand for no character. let to_glyph = m.inverse(); - let corners: Vec = - lay.glyphs.iter().filter(|h| h.byte == g.byte && h.line == g.line).filter_map(glyph_quad).flatten().map(|p| to_glyph * p).collect(); + let corners: Vec = lay + .glyphs + .iter() + .filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line) + .filter_map(glyph_quad) + .flatten() + .map(|p| to_glyph * p) + .collect(); let (mut x0, mut x1, mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY); for p in corners { (x0, x1, y0, y1) = (x0.min(p.x), x1.max(p.x), y0.min(p.y), y1.max(p.y)); @@ -174,7 +181,7 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option { along: if g.rtl { -d } else { d }, ex, ey, - kerned: !g.rtl && lay.glyphs.iter().any(|h| h.line == g.line && h.byte + h.len == g.byte), + kerned: !g.rtl && lay.glyphs.iter().any(|h| h.line == g.line && h.len > 0 && h.byte + h.len == g.byte), style: t.effective_char_style(&style)?, }) } @@ -342,7 +349,9 @@ impl Tool for TouchTypeTool { let Some(t) = text_of(cx, id) else { return out }; let lay = self.layout(t); let start = self.target.map_or(0, |(_, s)| s); - let Some(b) = lay.glyphs.iter().position(|g| g.byte == start).and_then(|gi| char_box(t, &lay, gi)) else { return out }; + let Some(b) = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0).and_then(|gi| char_box(t, &lay, gi)) else { + return out; + }; (Part::Move, b) } }; @@ -535,6 +544,37 @@ mod tests { assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); } + /// In a list item, the first character's box holds that character and not the bullet before + /// it, and a drag of the box moves that character. + #[test] + fn the_first_character_of_a_list_item_has_its_own_box_and_drags() { + let (mut d, id, s) = doc_with_type(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + } + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TouchTypeTool::default(); + let t_object = text_of(&cx, id).unwrap().clone(); + let lay = vectorcraft_text::layout(FontDb::global(), &t_object); + let first = lay.glyphs.iter().find(|g| g.byte == 0 && g.len > 0).unwrap(); + let at = t_object.xf * (first.origin + Vec2::new(first.advance / 2.0, -10.0)); + drag(&mut t, &cx, at, at); + assert_eq!(t.options(), json!({"id": id.0, "start": 0})); + let (q, pen) = (quad(&t, &cx), t_object.xf * first.origin); + assert!(q[0].x > pen.x - 5.0, "the box starts at the T, 18 pt past the bullet: {q:?}, the T at {pen:?}"); + let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -4.0)); + assert_eq!(preview(&acts[1])["start"].as_u64(), Some(0)); + // A drag on the character once the type is deselected selects the type and moves it. + let none = Selection::default(); + let cx = crate::testutil::cx(&d, &none, &p); + let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -4.0)); + assert_eq!(acts[0], vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + assert_eq!(preview(&acts[1])["start"].as_u64(), Some(0)); + } + #[test] fn dragging_the_box_moves_the_character_along_its_line_and_up() { let (d, id, s) = doc_with_type(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 081baf746..a1cd33dbd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2568,7 +2568,8 @@ impl HiddenMarks { } let text = tx.plain_text(); let to = |q: Point| tx.xf * q; - for g in &lay.glyphs { + // A list marker's glyphs (length 0) stand for no character. + for g in lay.glyphs.iter().filter(|g| g.len > 0) { if text.get(g.byte..).is_some_and(|s| s.starts_with(' ')) { marks.spaces.push(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0))); } @@ -6992,6 +6993,16 @@ mod tests { .collect() } + /// Show Hidden Characters puts a dot over each space of a list paragraph and none over its + /// bullet, which stands for no character. + #[test] + fn hidden_characters_mark_no_space_at_a_bullet() { + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, " Item two", vectorcraft_doc::CharStyle::default()); + let plain = HiddenMarks::of(&t).spaces.len(); + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + assert_eq!((plain, HiddenMarks::of(&t).spaces.len()), (2, 2)); + } + /// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before. /// Turning it off drops the marks with their text nodes, and turning it on again draws the /// same marks. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3e2543953..24b2a6ccf 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -339,6 +339,13 @@ font-technology Variable Променливи Built-in presets don't change: New… starts an editable copy. Вградените предварителни настройки не се променят: „Ново…“ създава редактируемо копие. Bulge… Издуване… Bullet Булет + Bullets Булети + Bullets and Numbering Булети и номериране + Apply Bullets Прилагане на булети + Filled Dot Плътна точка + Dash Тире + Hollow Circle Празен кръг + Filled Square Плътен квадрат Butt Cap Плосък край Byte Order: Подредба на байтовете: Calligraphic Калиграфски diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e2920016b..7f039c49b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -322,6 +322,13 @@ Glyphs Glyfy Insert Special Character Vložit speciální znak Bullet Odrážka + Bullets Odrážky + Bullets and Numbering Odrážky a číslování + Apply Bullets Použít odrážky + Filled Dot Plná tečka + Dash Pomlčka + Hollow Circle Prázdný kruh + Filled Square Plný čtverec Copyright Symbol Symbol copyrightu Ellipsis Výpustka Paragraph Symbol Symbol odstavce diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f82cab7b3..e7fc639cb 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -284,6 +284,13 @@ Glyphs Glyffer Insert Special Character Indsæt specialtegn Bullet Kugletegn + Bullets Kugletegn + Bullets and Numbering Kugletegn og nummerering + Apply Bullets Anvend kugletegn + Filled Dot Udfyldt prik + Dash Tankestreg + Hollow Circle Tom cirkel + Filled Square Udfyldt firkant Copyright Symbol Copyrighttegn Ellipsis Udeladelsesprikker Paragraph Symbol Afsnitstegn diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c2a69ff4e..bb6449b87 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -327,6 +327,13 @@ font-technology Variable Variabel Built-in presets don't change: New… starts an editable copy. Integrierte Vorgaben ändern sich nicht: „Neu…“ erstellt eine bearbeitbare Kopie. Bulge… Wulst… Bullet Aufzählungszeichen + Bullets Aufzählungszeichen + Bullets and Numbering Aufzählungszeichen und Nummerierung + Apply Bullets Aufzählungszeichen anwenden + Filled Dot Gefüllter Punkt + Dash Strich + Hollow Circle Leerer Kreis + Filled Square Gefülltes Quadrat Butt Cap Abgeflachter Abschluss Byte Order: Bytereihenfolge: Calligraphic Kalligrafie diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a229d3c4..f62c4aeee 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -327,6 +327,13 @@ font-technology Variable Μεταβλητές Built-in Libraries Ενσωματωμένες βιβλιοθήκες Bulge… Διόγκωση… Bullet Κουκκίδα + Bullets Κουκκίδες + Bullets and Numbering Κουκκίδες και αρίθμηση + Apply Bullets Εφαρμογή κουκκίδων + Filled Dot Γεμάτη κουκκίδα + Dash Παύλα + Hollow Circle Κενός κύκλος + Filled Square Γεμάτο τετράγωνο Butt Cap Επίπεδο άκρο Byte Order: Σειρά byte: Calligraphic Καλλιγραφικό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2f5ad6554..65fb05771 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -352,6 +352,13 @@ font-technology Variable Variables Built-in presets don't change: New… starts an editable copy. Los preajustes integrados no cambian: Nuevo… crea una copia editable. Bulge… Abultamiento… Bullet Viñeta + Bullets Viñetas + Bullets and Numbering Viñetas y numeración + Apply Bullets Aplicar viñetas + Filled Dot Punto relleno + Dash Guion + Hollow Circle Círculo vacío + Filled Square Cuadrado relleno Butt Cap Extremo a tope Byte Order: Orden de bytes: Calligraphic Caligráfico diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c6c65261d..e68615a57 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -319,6 +319,13 @@ Glyphs Merkit Insert Special Character Lisää erikoismerkki Bullet Luettelomerkki + Bullets Luettelomerkit + Bullets and Numbering Luettelomerkit ja numerointi + Apply Bullets Käytä luettelomerkkejä + Filled Dot Täytetty piste + Dash Ajatusviiva + Hollow Circle Ontto ympyrä + Filled Square Täytetty neliö Copyright Symbol Tekijänoikeusmerkki Ellipsis Kolme pistettä Paragraph Symbol Kappalemerkki diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a54ff430c..de673ad5e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -353,6 +353,13 @@ font-technology Variable Variables Built-in presets don't change: New… starts an editable copy. Les paramètres prédéfinis intégrés ne changent pas : Nouveau… crée une copie modifiable. Bulge… Renflement… Bullet Puce + Bullets Puces + Bullets and Numbering Puces et numérotation + Apply Bullets Appliquer les puces + Filled Dot Point plein + Dash Tiret + Hollow Circle Cercle vide + Filled Square Carré plein Butt Cap Extrémité plate Byte Order: Ordre des octets : Calligraphic Calligraphique diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c97307292..483c7b287 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -488,6 +488,13 @@ font-technology Variable Változó Built-in presets don't change: New… starts an editable copy. A beépített előbeállítások nem módosíthatók: az Új… szerkeszthető másolatot hoz létre. Bulge… Kidudorodás… Bullet Felsorolásjel + Bullets Felsorolásjelek + Bullets and Numbering Felsorolásjelek és számozás + Apply Bullets Felsorolásjelek alkalmazása + Filled Dot Kitöltött pont + Dash Gondolatjel + Hollow Circle Üres kör + Filled Square Kitöltött négyzet Burasagari Burasagari Burlap Zsákvászon Business Card 3.5 × 2 in Névjegykártya 3,5 × 2 in diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7e7d8f6c2..4ca95bc26 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -284,6 +284,13 @@ Glyphs Glif Insert Special Character Sisipkan karakter khusus Bullet Poin + Bullets Poin + Bullets and Numbering Poin dan penomoran + Apply Bullets Terapkan poin + Filled Dot Titik penuh + Dash Tanda pisah + Hollow Circle Lingkaran kosong + Filled Square Persegi penuh Copyright Symbol Simbol hak cipta Ellipsis Elipsis Paragraph Symbol Simbol paragraf diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index eff2f22d5..f0dc68f00 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -352,6 +352,13 @@ font-technology Variable Variabili Built-in presets don't change: New… starts an editable copy. I preset integrati non cambiano: Nuovo… crea una copia modificabile. Bulge… Rigonfiamento… Bullet Punto elenco + Bullets Punti elenco + Bullets and Numbering Punti elenco e numerazione + Apply Bullets Applica punti elenco + Filled Dot Punto pieno + Dash Trattino + Hollow Circle Cerchio vuoto + Filled Square Quadrato pieno Butt Cap Estremità tronca Byte Order: Ordine dei byte: Calligraphic Calligrafico diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 28e9c189d..7659cb693 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -312,6 +312,13 @@ font-technology Variable バリアブル Glyphs 字形 Insert Special Character 特殊文字を挿入 Bullet 箇条書き記号 + Bullets 箇条書き + Bullets and Numbering 箇条書きと番号付け + Apply Bullets 箇条書きを適用 + Filled Dot 黒丸 + Dash ダッシュ + Hollow Circle 白丸 + Filled Square 黒四角 Copyright Symbol 著作権記号 Ellipsis 省略記号 Paragraph Symbol 段落記号 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b0cac1a2c..9935d9266 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -243,6 +243,13 @@ Glyphs გლიფები Insert Special Character სპეციალური სიმბოლოს ჩასმა Bullet ბურთულა + Bullets ბურთულები + Bullets and Numbering ბურთულები და დანომრვა + Apply Bullets ბურთულების გამოყენება + Filled Dot სავსე წერტილი + Dash ტირე + Hollow Circle ცარიელი წრე + Filled Square სავსე კვადრატი Copyright Symbol საავტორო უფლების სიმბოლო Ellipsis მრავალწერტილი Paragraph Symbol აბზაცის სიმბოლო diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 19e50ba0c..fd569e06a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -308,6 +308,13 @@ font-technology Variable 가변 Glyphs 글리프 Insert Special Character 특수 문자 삽입 Bullet 글머리 기호 + Bullets 글머리 기호 + Bullets and Numbering 글머리 기호 및 번호 매기기 + Apply Bullets 글머리 기호 적용 + Filled Dot 채운 점 + Dash 대시 + Hollow Circle 빈 원 + Filled Square 채운 사각형 Copyright Symbol 저작권 기호 Ellipsis 줄임표 Paragraph Symbol 단락 기호 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 07c6d0986..5a9424c00 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -298,6 +298,13 @@ Glyphs Glyphs Insert Special Character Speciaal teken invoegen Bullet Opsommingsteken + Bullets Opsommingstekens + Bullets and Numbering Opsommingstekens en nummering + Apply Bullets Opsommingstekens toepassen + Filled Dot Gevulde stip + Dash Streepje + Hollow Circle Holle cirkel + Filled Square Gevuld vierkant Copyright Symbol Copyrightteken Ellipsis Beletselteken Paragraph Symbol Alineateken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index addd24227..3f902eab5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -283,6 +283,13 @@ Glyphs Glify Insert Special Character Wstaw znak specjalny Bullet Punktor + Bullets Punktory + Bullets and Numbering Punktory i numeracja + Apply Bullets Zastosuj punktory + Filled Dot Pełna kropka + Dash Myślnik + Hollow Circle Pusty okrąg + Filled Square Pełny kwadrat Copyright Symbol Symbol praw autorskich Ellipsis Wielokropek Paragraph Symbol Symbol akapitu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d8ab2d2f0..73cf8bda1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -322,6 +322,13 @@ Built-in presets don't change: New… starts an editable copy. Predefinidos integrados não mudam: Novo… inicia uma cópia editável. Bulge… Estufar… Bullet Marcador + Bullets Marcadores + Bullets and Numbering Marcadores e Numeração + Apply Bullets Aplicar Marcadores + Filled Dot Ponto Preenchido + Dash Travessão + Hollow Circle Círculo Vazado + Filled Square Quadrado Preenchido Butt Cap Extremidade Reta Byte Order: Ordem dos Bytes: CMYK CMYK diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d2d9467b7..0d643f79f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -350,6 +350,13 @@ font-technology Variable Вариативные Built-in presets don't change: New… starts an editable copy. Встроенные наборы не изменяются: «Создать…» создаёт редактируемую копию. Bulge… Выпуклость… Bullet Маркер + Bullets Маркеры + Bullets and Numbering Маркеры и нумерация + Apply Bullets Применить маркеры + Filled Dot Закрашенная точка + Dash Тире + Hollow Circle Пустой круг + Filled Square Закрашенный квадрат Butt Cap Стыковое окончание Byte Order: Порядок байтов: Calligraphic Каллиграфическая diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 1bed20fb7..b918d206e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -341,6 +341,13 @@ font-technology Variable Variabla Built-in presets don't change: New… starts an editable copy. Inbyggda förinställningar ändras inte: Ny… skapar en redigerbar kopia. Bulge… Utbuktning… Bullet Punkt + Bullets Punkter + Bullets and Numbering Punkter och numrering + Apply Bullets Använd punkter + Filled Dot Fylld punkt + Dash Tankstreck + Hollow Circle Tom cirkel + Filled Square Fylld kvadrat Butt Cap Rak ände Byte Order: Byteordning: Calligraphic Kalligrafisk diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 606dbf62a..61ed6ad2b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -336,6 +336,13 @@ font-technology Variable Değişken Built-in presets don't change: New… starts an editable copy. Yerleşik ön ayarlar değişmez: Yeni… düzenlenebilir bir kopya başlatır. Bulge… çıkıntı… Bullet Madde işareti + Bullets Madde İşaretleri + Bullets and Numbering Madde İşaretleri ve Numaralandırma + Apply Bullets Madde İşaretlerini Uygula + Filled Dot Dolu Nokta + Dash Tire + Hollow Circle Boş Daire + Filled Square Dolu Kare Butt Cap Popo Kapağı Byte Order: Bayt Sırası: Calligraphic Kaligrafi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4a44f1d82..dc9e49c69 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -346,6 +346,13 @@ font-technology Variable Варіативні Built-in presets don't change: New… starts an editable copy. Вбудовані набори не змінюються: «Створити…» створює копію, що редагується. Bulge… Випуклість… Bullet Маркер + Bullets Маркери + Bullets and Numbering Маркери та нумерація + Apply Bullets Застосувати маркери + Filled Dot Заповнена крапка + Dash Тире + Hollow Circle Порожнє коло + Filled Square Заповнений квадрат Butt Cap Пряме закінчення Byte Order: Порядок байтів: Calligraphic Каліграфічна diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c2c11bb6f..23d182d0a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -406,6 +406,13 @@ font-technology Variable Biến thiên Built-in presets don't change: New… starts an editable copy. Thiết lập sẵn tích hợp không thay đổi: Mới… tạo một bản sao có thể chỉnh sửa. Bulge… Phình… Bullet Dấu đầu dòng + 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 + Filled Dot Chấm đặc + Dash Gạch ngang + Hollow Circle Hình tròn rỗng + Filled Square Hình vuông đặc Burasagari Burasagari Burlap Vải thô Butt Cap Đầu nét vuông diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7f3a7bea2..ccc0f29ae 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -301,6 +301,13 @@ Built-in presets don't change: New… starts an editable copy. 内置预设不会变动:“新建…”会创建可编辑的副本。 Bulge… 鼓起… Bullet 项目符号 + Bullets 项目符号 + Bullets and Numbering 项目符号和编号 + Apply Bullets 应用项目符号 + Filled Dot 实心圆点 + Dash 短划线 + Hollow Circle 空心圆 + Filled Square 实心方块 Butt Cap 平头端点 Byte Order: 字节顺序: Calligraphic 书法 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b08016c86..e1c24b426 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -314,6 +314,13 @@ font-technology Variable 可變 Built-in presets don't change: New… starts an editable copy. 內建預設集不會變動:「新增…」會建立可編輯的複本。 Bulge… 鼓起… Bullet 項目符號 + Bullets 項目符號 + Bullets and Numbering 項目符號與編號 + Apply Bullets 套用項目符號 + Filled Dot 實心圓點 + Dash 短破折號 + Hollow Circle 空心圓 + Filled Square 實心方塊 Butt Cap 平端點 Byte Order: 位元組順序: Calligraphic 書法 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index a2fa99485..f19e0a193 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -117,6 +117,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-new-stroke", include_bytes!("../../../assets/icons/dc-new-stroke.svg")), ("dc-objects-on-path", include_bytes!("../../../assets/icons/dc-objects-on-path.svg")), ("dc-options", include_bytes!("../../../assets/icons/dc-options.svg")), + ("dc-para-bullets", include_bytes!("../../../assets/icons/dc-para-bullets.svg")), ("dc-para-center", include_bytes!("../../../assets/icons/dc-para-center.svg")), ("dc-para-justify-all", include_bytes!("../../../assets/icons/dc-para-justify-all.svg")), ("dc-para-justify-center", include_bytes!("../../../assets/icons/dc-para-justify-center.svg")), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c0f58843..df1a9bbaa 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -224,6 +224,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"), ("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"), ("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"), + ( + "type.applyBullets", + "Apply Bullets", + "", + "{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", + ), ( "align.horizontalLeft", "Horizontal Align Left", @@ -1409,6 +1415,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { crate::panels::paragraph::align(app, justify) } + "type.applyBullets" => crate::panels::paragraph::apply_bullets(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), @@ -2494,6 +2501,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(), 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()), @@ -2988,6 +2996,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")]), Sep, c("Create Outlines", "type.createOutlines"), c("Scale Text Attributes", "type.scaleTextAttributes"), @@ -3366,6 +3375,10 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { if any(|k| matches!(k, NodeKind::Compound { .. })) { v.push(c("Release Compound Path", "object.compoundPath.release")); } + // 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.extend([ c("Make Guides", "view.guides.make"), Sep, diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 9c391d65c..a60454f6e 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -1,7 +1,8 @@ -//! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), 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. +//! 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. use egui::{Ui, vec2}; use serde_json::{Value, json}; @@ -332,6 +333,85 @@ pub fn alignment_buttons(app: &mut VectorcraftApp, ui: &mut Ui, para: &ParaStyle } } +/// Type › Bullets and Numbering › Apply Bullets (`type.applyBullets {bullet?}`): the paragraphs of +/// the selected type, or those the Type tool's selection touches, become a bulleted list with +/// `bullet`; without it, a list keeps its bullet and other paragraphs take the dot. +pub(crate) fn apply_bullets(app: &mut VectorcraftApp, p: &Value) -> Result { + text_style(app).ok_or("select some type first")?; + let params = match p.get("bullet") { + Some(b) => json!({ "bullet": b }), + None => json!({ "list": "bullet" }), + }; + 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 +/// one every paragraph has is checked. +pub fn bullets_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { + use vectorcraft_doc::text::Bullet; + let t = Tokens::get(ui.ctx()); + let shown = shared_para(app, |p| p.list.map(|l| l.bullet)); + if widgets::icon_button(ui, "dc-para-bullets", tl!("Bullets"), matches!(shown, Some(Some(_))), size).clicked() { + format(app, json!({"list": "bullet"})); + } + let (rect, arrow) = ui.allocate_exact_size(vec2(14.0, size), egui::Sense::click()); + if arrow.hovered() { + ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); + } + crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Bullets"))); + let arrow = arrow.on_hover_text(tl!("Bullets")); + let presets = [ + (tl!("None"), "none", None), + (tl!("Filled Dot"), "dot", Some(Bullet::Dot)), + (tl!("Dash"), "dash", Some(Bullet::Dash)), + (tl!("Hollow Circle"), "circle", Some(Bullet::Circle)), + (tl!("Filled Square"), "square", Some(Bullet::Square)), + ]; + let picked = egui::Popup::menu(&arrow).show(|ui| { + let mut picked = None; + for (name, key, bullet) in presets { + if bullet_row(ui, name, bullet, shown == Some(bullet)) { + picked = Some(key); + } + } + picked + }); + if let Some(key) = picked.and_then(|r| r.inner) { + format(app, json!({ "bullet": key })); + } +} + +/// A row of the Bullets presets: a check when `checked`, the bullet drawn (a slash for None), then +/// its name. Whether it was clicked. +fn bullet_row(ui: &mut Ui, name: &str, bullet: Option, checked: bool) -> bool { + use vectorcraft_doc::text::Bullet; + 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); + } + let c = rect.left_center() + vec2(28.0, 0.0); + let stroke = egui::Stroke::new(1.5, t.text_strong); + match bullet { + None => painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke), + Some(Bullet::Dot) => painter.circle_filled(c, 3.0, t.text_strong), + Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke), + Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, t.text_strong)), + Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, t.text_strong), + }; + painter.text(rect.left_center() + vec2(42.0, 0.0), egui::Align2::LEFT_CENTER, name, font, t.text_strong); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), checked, name)); + 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); @@ -363,6 +443,10 @@ 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(4.0); let fw = ((ui.available_width() - 66.0) / 2.0).clamp(60.0, 100.0); // Indents and paragraph spacing are distances (General); type sizes follow Units ▸ Type. @@ -523,10 +607,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no indents or -/// paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto -/// direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the Every-line -/// Composer, Roman Hanging Punctuation off and no kashidas. +/// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no +/// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification +/// options, Auto direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the +/// Every-line Composer, Roman Hanging Punctuation off and no kashidas. fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); @@ -534,7 +618,7 @@ fn reset_panel(app: &mut VectorcraftApp) { format( app, json!({ - "leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false, + "list": "none", "leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false, "wordsLongerThan": h.words_longer_than, "afterFirst": h.after_first, "beforeLast": h.before_last, "hyphenLimit": h.hyphen_limit, "hyphenationZone": h.zone, "hyphenateCapitalized": h.capitalized, "kashida": "none", "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, @@ -864,6 +948,53 @@ mod tests { assert_eq!(kashida(&app), vectorcraft_doc::text::Kashida::None); } + /// The Bullets button makes the paragraphs the Type tool's selection touches a bulleted list + /// with the dot and shows pressed; the arrow's presets pick another bullet, None turns the list + /// off, Type › Bullets and Numbering › Apply Bullets applies a list, and Reset Panel turns it + /// off. + #[test] + fn the_bullets_button_and_its_presets_set_the_list_and_reset_panel_clears_it() { + use vectorcraft_doc::text::{Bullet, 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 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| bullets_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), (Some(ParaList::bulleted(Bullet::Dot)), None), "the caret's paragraph"); + assert_eq!(shared_para(&app, |p| p.list.map(|l| l.bullet)), Some(Some(Bullet::Dot)), "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); + }; + pick(&mut app, "Filled Square"); + assert_eq!(lists(&app), (Some(ParaList::bulleted(Bullet::Square)), None)); + pick(&mut app, "None"); + assert_eq!(lists(&app), (None, None)); + app.session.set_tool_option("selectAll", &json!(true)); + app.run("type.applyBullets", json!({})).unwrap(); + assert_eq!(lists(&app), (Some(ParaList::bulleted(Bullet::Dot)), Some(ParaList::bulleted(Bullet::Dot)))); + app.run("type.applyBullets", json!({"bullet": "dash"})).unwrap(); + assert_eq!(lists(&app).0, Some(ParaList::bulleted(Bullet::Dash))); + 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 33f604af0..6ddde2ac5 100644 --- a/crates/ui-egui/src/tests_contextmenu.rs +++ b/crates/ui-egui/src/tests_contextmenu.rs @@ -213,6 +213,26 @@ fn the_menu_follows_the_selection() { assert!(items.windows(2).all(|w| !matches!(w, [Item::Sep, Item::Sep]))); } +/// Selected type has Bullets and Numbering › Apply Bullets in the context menu and the Type menu, +/// which makes its paragraphs a bulleted list; other art has no such item. +#[test] +fn selected_type_has_apply_bullets() { + let mut app = app(); + let r = rect(&mut app, 0.0, 0.0); + app.run("select.set", json!({"ids": [r.0]})).unwrap(); + let bullets = |app: &VectorcraftApp| menus::context_entries(app).into_iter().find(|e| e.command.as_deref() == Some("type.applyBullets")); + assert!(bullets(&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 e = bullets(&app).expect("Apply Bullets"); + assert_eq!((e.path.as_slice(), e.label.as_str(), e.enabled), (&["Bullets and Numbering".to_string()][..], "Apply Bullets", true)); + let typed = menus::menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("type.applyBullets")).expect("in the Type menu"); + assert_eq!(typed.path, ["Type", "Bullets and Numbering"]); + menus::invoke(&mut app, "type.applyBullets", json!({})); + let Some(NodeKind::Text(text)) = app.session.active().unwrap().doc.node(t).map(|n| &n.kind) else { panic!("text") }; + assert!(text.para_at(0).list.is_some()); +} + /// 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 9b56fa155..219114f0d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4287,6 +4287,48 @@ 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 + +`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. + +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 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. + +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}`. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"bullet":"square"}}} +``` + ## Small Caps, Superscript and Subscript `text.setFormat {smallCaps, position}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, smallCaps, position}` (a range) set what the Character panel's Small Caps, Superscript and Subscript buttons and menu items set. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c4b436165..746df085e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -767,6 +767,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. 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. 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 text and 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 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`).