Merge pull request #3780 from dotCooCoo/feat/paragraph-bullets

Type: the Paragraph panel's Bullets button turns paragraphs into a bulleted list
This commit is contained in:
BFlat
2026-10-24 03:13:19 +07:00
committed by GitHub
52 changed files with 1513 additions and 96 deletions
+1
View File
@@ -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 | |
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><circle cx="4.5" cy="6" r="1.6" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.6" fill="currentColor" stroke="none"/><path d="M9 6H21" stroke-width="1.5" /><path d="M9 12H21" stroke-width="1.5" /><path d="M9 18H21" stroke-width="1.5" /></svg>

After

Width:  |  Height:  |  Size: 511 B

+10 -3
View File
@@ -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 };
+36
View File
@@ -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() {
+83
View File
@@ -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<TabStop>,
/// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not
/// in a list.
pub list: Option<ParaList>,
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
pub style_name: Option<String>,
/// 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<TabStop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
list: Option<ParaList>,
#[serde(default, skip_serializing_if = "Option::is_none")]
style_name: Option<String>,
#[serde(default, skip_serializing_if = "Mojikumi::is_none")]
mojikumi: Mojikumi,
@@ -798,6 +877,7 @@ impl From<ParaStyle> for ParaStyleFile {
justification,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
@@ -821,6 +901,7 @@ impl From<ParaStyle> for ParaStyleFile {
justification,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
@@ -848,6 +929,7 @@ impl From<ParaStyleFile> for ParaStyle {
hyphenation,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
@@ -870,6 +952,7 @@ impl From<ParaStyleFile> for ParaStyle {
justification,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
+3 -3
View File
@@ -1290,9 +1290,9 @@ fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
}
}
/// 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<Option<&'a str>> {
/// 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<Option<&'a str>> {
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)),
File diff suppressed because one or more lines are too long
+9 -6
View File
@@ -715,7 +715,8 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
// 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::<f64>();
let advance = |o: &TextObject| {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().filter(|h| h.len > 0).map(|h| h.advance).sum::<f64>()
};
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<Value> {
// 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<f64>, Option<f64>)| {
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,
};
+2 -1
View File
@@ -168,7 +168,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
/// 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<String, Value> {
let mut m = to_attrs(v);
for (k, x) in [
@@ -176,6 +176,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
("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)),
+86
View File
@@ -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::<vectorcraft_doc::ParaStyle>(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
+17
View File
@@ -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();
+23
View File
@@ -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::<Vec<_>>())
};
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]
+2 -2
View File
@@ -83,7 +83,7 @@ const HEX_LINE: usize = 60;
pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)";
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, 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())
+15
View File
@@ -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};
+49 -14
View File
@@ -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 `<textPath>` 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 <text> 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 `<tspan>` cannot undo the decoration of the `<text>` around it: with bullets, which
// have none, each run's `<tspan>` 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<Arc<FontFace>> = 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<Segment> = 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));
+102 -3
View File
@@ -254,12 +254,13 @@ fn canvas_glyph_positions(d: &Document) -> (Vec<Drawn>, Vec<Arc<vectorcraft_text
let mut out: Vec<Drawn> = vec![];
let mut faces: Vec<Arc<vectorcraft_text::FontFace>> = 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 `<tspan …>` start tag with the text after it.
let pieces: Vec<(&str, &str)> = svg.split("<tspan").skip(1).filter_map(|p| p.split_once('>')).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("<tspan").skip(1).filter_map(|p| p.split_once('>')).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("<text").nth(1).and_then(|p| p.split('>').next()).expect("a <text>");
let pieces: Vec<(&str, &str)> = svg.split("<tspan").skip(1).filter_map(|p| p.split_once('>')).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 `<textPath>` 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("<textPath"), warned), (list.is_none(), list.is_some()), "{:?}", out.warnings);
}
}
/// A Justify All line without word spaces spreads its letters; live text keeps the gaps, with
/// tracking and manual kerning too.
#[test]
@@ -381,6 +471,15 @@ fn ragged_lines_keep_their_character_spacing() {
}
}
/// Type scaled horizontally before a tab is stretched to its own width, not to the tab's gap.
#[test]
fn scaled_type_before_a_tab_keeps_its_width() {
for h_scale in [50.0, 150.0] {
let scaled = CharStyle { h_scale, ..style(12.0) };
assert_drawn_in_place(&area_doc(vec![run("ab\tcd ef\tgh", scaled)], 280.0, Justify::Left));
}
}
/// Justified Japanese lines spread between their characters (#344); live text keeps the gaps.
#[test]
fn justified_japanese_lines_keep_the_gaps_between_their_characters() {
+199 -38
View File
@@ -20,7 +20,7 @@ use crate::shape::{
Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start,
style_metrics,
};
use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign};
use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign};
const EPS: f64 = 1e-6;
@@ -72,9 +72,17 @@ impl Ctx<'_> {
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<usize>, para: &ParaStyle, rtl: bool, sg: &mut Vec<SGlyph>) -> usize {
let Some(list) = para.list.filter(|_| !pr.is_empty()) else { return 0 };
let text = list.marker();
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::<f64>();
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(&para.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<usize>], 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, &params(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, &params(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<usize>], t: &TextObject, regions: Option<&[Region]>) {
@@ -1961,12 +2046,24 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], 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, &para.justification, !cx.vertical);
let mut flex = apply_desired(&mut sg, &para.justification, !cx.vertical);
// Bullets: the list marker's glyphs `0..marker` start the paragraph. Justification and
// Optical Margin Alignment leave them as they are.
let marker = cx.prepend_marker(pr, para, rtl, &mut sg);
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<usize>], 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<usize>], 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((&para.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<usize>], 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<usize>], t: &TextObject, regions: Optio
// Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs.
let share: Option<fn(&SGlyph) -> 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<usize>, Vec<(&SGlyph, f64)>) {
let at: Vec<usize> = 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<usize>], 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<usize>], 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<usize>], 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<Flex> = (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<usize>], 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<usize>], 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<Range<usize>> = 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<usize>], t: &TextObject, regions: Optio
let mut scale = 1.0;
let mut leader = "";
if g.ch == '\t' {
(adv, leader) = tab_advance(&para.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(&para.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<usize>], 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<usize>| 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<usize>], t: &TextObject, regions: Optio
x_end = x;
}
}
if let (Some(glyphs), Some(list)) = (marker_glyphs, para.list) {
cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: list.marker() });
}
if let Some(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<usize>], t: &TextObject, path: &Path
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
let mut rtl = None;
let mut shaped = Vec::new();
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<usize>], 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<usize>], 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<Range<usize>> = 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<usize> = 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<usize, Vec<usize>> = 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);
}
}
+55 -10
View File
@@ -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<usize>,
/// 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<InlineGlyph>,
/// List markers, in text order.
pub markers: Vec<ListMarker>,
}
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.
+333
View File
@@ -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::<Vec<_>>(), [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<usize> = 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 {
+17
View File
@@ -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);
+45 -5
View File
@@ -152,10 +152,17 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
}
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<Point> =
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<Point> = 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<CharBox> {
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();
+12 -1
View File
@@ -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.
+7
View File
@@ -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 Калиграфски
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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 Καλλιγραφικό
1 # Ελληνικά (el) — VectorCraft
327
328
329
330
331
332
333
334
335
336
337
338
339
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
284
285
286
287
288
289
290
291
292
293
294
295
296
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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 段落記号
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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 აბზაცის სიმბოლო
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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 단락 기호
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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 Каллиграфическая
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -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 Каліграфічна
1 # Ukrainian (uk) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation.
346
347
348
349
350
351
352
353
354
355
356
357
358
+7
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
406
407
408
409
410
411
412
413
414
415
416
417
418
+7
View File
@@ -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 书法
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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 書法
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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")),
+13
View File
@@ -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<R
id if let Some((_, justify)) = crate::panels::paragraph::ALIGN_COMMANDS.iter().find(|c| c.0 == id) => {
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<Item>)> {
],
),
cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})),
sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets")]),
Sep,
c("Create Outlines", "type.createOutlines"),
c("Scale Text Attributes", "type.scaleTextAttributes"),
@@ -3366,6 +3375,10 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
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,
+140 -9
View File
@@ -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<Value, String> {
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<vectorcraft_doc::text::Bullet>, 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.
+20
View File
@@ -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() {
+42
View File
@@ -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.
+1
View File
@@ -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 first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. Numbered lists, the Bullets and Numbering dialog, levels, list detection while typing and Convert to Text are not built yet.
The 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`).