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`).