Merge pull request #4098 from dotCooCoo/feat/pdf-list-markers-as-text

PDF: list markers export as selectable text
This commit is contained in:
BFlat
2026-10-26 21:02:27 +07:00
committed by GitHub
4 changed files with 279 additions and 22 deletions
+76 -16
View File
@@ -21,8 +21,8 @@ use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject};
use vectorcraft_effects::stroke::{self, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
use vectorcraft_text::TextLayout;
use vectorcraft_text::embed::Embedding;
use vectorcraft_text::{ListMarker, TextLayout};
use crate::forms::Mark;
use crate::lab_spot::{find, rfind};
@@ -694,8 +694,11 @@ struct GlyphLine {
/// The glyph space (krilla's, y down, at `size`) → text space, at the line's start.
frame: Affine,
/// Glyph id, pen position and advance along the line (in glyph space), and the characters it
/// stands for (bytes of the plain text).
/// stands for (bytes of the plain text, or of the list marker's text).
glyphs: Vec<(u32, f64, f64, std::ops::Range<usize>)>,
/// The list marker the glyphs belong to, by its first glyph's index in the layout
/// ([`TextLayout::marker_of`]); `None` for glyphs of the plain text.
marker: Option<usize>,
}
/// Most pixels along a side of a freeform gradient's image (its colour field is smooth).
@@ -1780,7 +1783,9 @@ impl Exporter<'_> {
/// COLR, SVG), with `images`, are written as text too, which draws their images or layers; the
/// indices of those it can't write or draw (SVG glyphs) are returned with the outlines. The
/// hyphen the layout adds where it breaks a line inside a word ([`break_hyphen`]) is written
/// as the character its glyph stands for, on its own after the glyphs before it.
/// as the character its glyph stands for, on its own after the glyphs before it. The glyphs of
/// a list marker are written as the marker's characters ([`marker_chars`]), apart from the
/// plain text's glyphs; a marker glyph that stands for none of them is drawn as a path.
///
/// `bent`: `Some(false)` writes the glyphs bent along a path ([`vectorcraft_text::GlyphBend`])
/// as outlines, not text (text is set straight); `Some(true)` writes only those, as text where
@@ -1802,6 +1807,11 @@ impl Exporter<'_> {
// stand for no character of it, the characters a line-break hyphen can stand for.
let written: Cow<str> =
if layout.glyphs.iter().any(|g| g.run == run && g.len == 0) { Cow::Owned(format!("{text}{BREAK_HYPHENS}")) } else { Cow::Borrowed(text) };
let written_text: &str = &written;
// The text a line's glyphs stand for: `written`, or its list marker's characters.
let text_of = |l: &GlyphLine| l.marker.and_then(|m| layout.marker_of(m)).map_or(written_text, |m| m.text.as_str());
// The characters of the list marker being written that each of its glyphs stands for.
let mut marker: Option<(usize, Vec<Option<std::ops::Range<usize>>>)> = None;
for (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) {
let coloured = g.image.is_some() || g.colour.is_some();
if coloured && !images {
@@ -1815,11 +1825,27 @@ impl Exporter<'_> {
(Some(true), false) => continue,
_ => {}
}
// A list marker's glyphs stand for no character of the text either; they are not
// line-break hyphens, whatever the marker.
let hyphen = if layout.marker_of(gi).is_some() { None } else { break_hyphen(g) };
let range = hyphen.clone().map_or(g.byte..g.byte + g.len, |h| text.len() + h.start..text.len() + h.end);
let ch = written.get(range.clone()).and_then(|t| t.chars().next());
// Of the glyphs with no length, only a list marker's and line-break hyphens are written
// as text; the others, such as kashidas, are drawn as paths. A list marker's glyphs are
// not line-break hyphens, whatever the marker.
let in_marker = (g.len == 0).then(|| layout.marker_of(gi)).flatten();
let hyphen = if in_marker.is_some() { None } else { break_hyphen(g) };
let (range, ch) = match in_marker {
Some(m) => {
if marker.as_ref().is_none_or(|(start, _)| *start != m.glyphs.start) {
marker = Some((m.glyphs.start, marker_chars(layout, m)));
}
let range = marker.as_ref().and_then(|(start, chars)| chars.get(gi.checked_sub(*start)?).cloned().flatten());
let ch = range.clone().and_then(|r| m.text.get(r)).and_then(|t| t.chars().next());
(range.unwrap_or(0..0), ch)
}
None => {
let range = hyphen.clone().map_or(g.byte..g.byte + g.len, |h| text.len() + h.start..text.len() + h.end);
let ch = written.get(range.clone()).and_then(|t| t.chars().next());
(range, ch)
}
};
let marker_at = in_marker.map(|m| m.glyphs.start);
// Spaces are written too (text extraction needs them); control characters and soft
// hyphens draw nothing.
let blank = g.outline.elements().is_empty() && !coloured;
@@ -1852,18 +1878,22 @@ impl Exporter<'_> {
let frame = g.xf * Affine::scale(1.0 / sy);
// Its pen position along the frame's baseline, when it continues the line (a
// line-break hyphen never does).
let x = line.as_ref().filter(|l| hyphen.is_none() && l.face == g.font_id && (l.size - size).abs() < 1e-6).and_then(|l| {
let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs();
let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4;
same.then_some(e)
});
let x = line
.as_ref()
.filter(|l| hyphen.is_none() && l.face == g.font_id && (l.size - size).abs() < 1e-6 && l.marker == marker_at)
.and_then(|l| {
let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs();
let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4;
same.then_some(e)
});
let x = match x {
Some(x) => x,
None => {
if let Some(l) = line.take() {
self.draw_glyph_line(s, l, &written, fill, layout.bounds);
let text = text_of(&l);
self.draw_glyph_line(s, l, text, fill, layout.bounds);
}
line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![] });
line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![], marker: marker_at });
0.0
}
};
@@ -1880,7 +1910,8 @@ impl Exporter<'_> {
}
}
if let Some(l) = line {
self.draw_glyph_line(s, l, &written, fill, layout.bounds);
let text = text_of(&l);
self.draw_glyph_line(s, l, text, fill, layout.bounds);
}
(rest, unwritten)
}
@@ -2129,6 +2160,35 @@ fn break_hyphen(g: &vectorcraft_text::PositionedGlyph) -> Option<std::ops::Range
BREAK_HYPHENS.char_indices().find(|&(_, c)| face.glyph_for(c) == g.gid).map(|(i, c)| i..i + c.len_utf8())
}
/// The characters of list marker `m` of `layout` that each of its glyphs stands for, as bytes of
/// the marker's text, in the order of [`ListMarker::glyphs`]: `None` for its tab and for a glyph
/// that stands for none of them. A glyph stands for the first character left that the glyph's
/// font maps to that glyph in its character map. When the glyphs that no character maps to (an
/// OpenType feature such as oldstyle figures replaced them) are as many as the characters left,
/// they stand for those characters, in order.
fn marker_chars(layout: &TextLayout, m: &ListMarker) -> Vec<Option<std::ops::Range<usize>>> {
let db = vectorcraft_text::FontDb::global();
let mut left: Vec<(std::ops::Range<usize>, char)> = m.text.char_indices().map(|(i, c)| (i..i + c.len_utf8(), c)).collect();
let mut chars = vec![None; m.glyphs.len()];
let mut unmapped = vec![];
for (k, gi) in m.glyphs.clone().enumerate() {
let Some(g) = layout.glyphs.get(gi).filter(|g| gi != m.tab && g.gid != 0) else { continue };
let face = db.face_by_id(g.font_id);
match (left.iter().position(|(_, c)| face.as_ref().is_some_and(|f| f.glyph_for(*c) == g.gid)), chars.get_mut(k)) {
(Some(at), Some(slot)) => *slot = Some(left.remove(at).0),
_ => unmapped.push(k),
}
}
if unmapped.len() == left.len() {
for (k, (range, _)) in unmapped.into_iter().zip(left) {
if let Some(slot) = chars.get_mut(k) {
*slot = Some(range);
}
}
}
chars
}
#[cfg(test)]
mod tests {
use super::*;
+193 -2
View File
@@ -5,8 +5,9 @@ use std::sync::Arc;
use serde_json::json;
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
use vectorcraft_doc::{CharStyle, Document, Effect, Node, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
use vectorcraft_doc::{CharStyle, Document, Effect, Node, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
use vectorcraft_text::{FALLBACK_FAMILY, FontDb};
use crate::*;
@@ -303,6 +304,196 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
}
}
/// Area type 200 points wide holding `text`, its paragraphs in lists `lists`, with an 18 pt
/// hanging indent.
fn listed(text: &str, lists: &[Option<ParaList>]) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, style(12.0));
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 20.0, 220.0, 280.0)) };
t.xf = Affine::IDENTITY;
t.para.left_indent = 18.0;
t.para.first_line_indent = -18.0;
let styles = lists.iter().map(|&list| ParaStyle { list, ..t.para.clone() }).collect();
t.set_paragraph_styles(styles);
t
}
/// The origins (document space) of the glyphs of each list marker of the type objects of `d`, in
/// the order they are drawn, the marker's tab left out.
fn marker_origins(d: &Document) -> Vec<Vec<Point>> {
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let lay = vectorcraft_text::layout(FontDb::global(), t);
for m in &lay.markers {
out.push(m.glyphs.clone().filter(|&i| i != m.tab).map(|i| t.xf * lay.glyphs[i].origin).collect());
}
}
});
out
}
/// Each glyph of the type objects of `d` that stands for characters of its text: the first of
/// them and the glyph's origin in document space.
fn glyph_chars(d: &Document) -> Vec<(char, Point)> {
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let text = t.plain_text();
for g in vectorcraft_text::layout(FontDb::global(), t).glyphs.iter().filter(|g| g.len > 0) {
if let Some(c) = text.get(g.byte..).and_then(|s| s.chars().next()) {
out.push((c, t.xf * g.origin));
}
}
}
});
out
}
/// Does `text` hold `a` before `b`?
fn before(text: &str, a: &str, b: &str) -> bool {
matches!((text.find(a), text.find(b)), (Some(x), Some(y)) if x < y)
}
/// `back`, the export of `d` reopened, has glyphs of the characters `drawn` (each list marker's,
/// in the order the canvas draws them) within `near` points of where `d` draws its markers'
/// glyphs.
fn assert_markers_in_place(d: &Document, back: &Document, drawn: &[&str], near: f64) {
let want = marker_origins(d);
assert_eq!(want.len(), drawn.len(), "{drawn:?}");
let got = glyph_chars(back);
for (origins, chars) in want.iter().zip(drawn) {
assert_eq!(origins.len(), chars.chars().count(), "{chars}");
for (p, c) in origins.iter().zip(chars.chars()) {
assert!(got.iter().any(|(gc, gp)| *gc == c && gp.distance(*p) < near), "{c:?} at {p:?}: {got:?}");
}
}
}
/// List markers (Paragraph panel › Bullets and Numbered List) are written as text: reopened, the
/// export holds each bullet and number as characters, before its paragraph's text and where the
/// canvas draws them, and the page looks as it does with outlined type.
#[test]
fn list_markers_are_written_as_text_where_the_canvas_draws_them() {
let number = Some(ParaList::numbered(NumberStyle::Arabic));
for (bullet, character) in [(Bullet::Dot, "\u{2022}"), (Bullet::Dash, "\u{2013}"), (Bullet::Circle, "\u{25E6}"), (Bullet::Square, "\u{25AA}")] {
let bullet = Some(ParaList::bulleted(bullet));
let t = listed("alpha beta gamma delta epsilon zeta eta theta\nbullet\nfirst\nsecond", &[bullet, bullet, number, number]);
let d = doc(vec![t]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
let all = texts(&back).concat();
assert_eq!(all.matches(character).count(), 2, "{all}");
assert!(before(&all, character, "alpha") && before(&all, "1.", "first") && before(&all, "2.", "second"), "{all}");
assert_markers_in_place(&d, &back, &[character, character, "1.", "2."], 0.1);
let (real, outlined) = (ink(&import_as(&pdf(&d, false).bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
let off =
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(off < 0.5, "{character}: {real:?} vs {outlined:?}");
}
}
/// A right-to-left numbered paragraph's number is written as its characters where the canvas
/// draws them (the period, then 1 and 2, from the left), and the paragraph's own text comes back
/// as it does without a list.
#[test]
fn a_right_to_left_number_is_written_as_text_where_the_canvas_draws_it() {
let hebrew = "שלום עולם";
let mut plain = listed(hebrew, &[None]);
plain.edit_paras(None, |p| p.justify = vectorcraft_doc::Justify::Auto);
let mut numbered = plain.clone();
numbered.edit_paras(None, |p| p.list = Some(ParaList::numbered(NumberStyle::Arabic)));
numbered.list_count = 11;
let d = doc(vec![numbered]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
// The reader tracks a reopened right-to-left line as a whole, and the gap before the number
// comes back as a space of the Hebrew font's width, so the number may sit up to a tenth of an
// em (1.2 pt) away; its characters keep their spacing to within 0.1 pt.
assert_markers_in_place(&d, &back, &[".12"], 1.2);
let drawn = marker_origins(&d).concat();
let got = glyph_chars(&back);
let at = |c: char| got.iter().find(|(gc, _)| *gc == c).map(|(_, p)| p.x);
for (k, (a, b)) in [('.', '1'), ('1', '2')].into_iter().enumerate() {
let (Some(pa), Some(pb), Some(da), Some(db)) = (at(a), at(b), drawn.get(k), drawn.get(k + 1)) else {
panic!("{a:?} and {b:?} in {got:?}");
};
assert!(((pb - pa) - (db.x - da.x)).abs() < 0.1, "{a:?} to {b:?}: {} against {}", pb - pa, db.x - da.x);
}
let without = texts(&import_as(&pdf(&doc(vec![plain]), false).bytes, TextAs::Text)).concat();
let all = texts(&back).concat();
assert!(all.contains(without.trim()), "{all:?} holds {without:?}");
}
/// Type on a path writes its bullet as text where the canvas draws it along the path.
#[test]
fn a_bullet_on_a_path_is_written_as_text_where_the_canvas_draws_it() {
let mut t = TextObject::point(Point::ZERO, "along the arc", style(18.0));
t.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
t.para.list = Some(ParaList::bulleted(Bullet::Dot));
let d = doc(vec![t]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
assert!(texts(&back).concat().contains('\u{2022}'), "{:?}", texts(&back));
assert_markers_in_place(&d, &back, &["\u{2022}"], 0.3);
}
/// Threaded frames write each marker their layouts draw as text, the number of the paragraph the
/// first frame splits included, in each frame where the canvas draws it.
#[test]
fn numbers_in_threaded_frames_are_written_as_text_in_each_frame() {
let numbered =
ParaStyle { list: Some(ParaList::numbered(NumberStyle::Arabic)), left_indent: 18.0, first_line_indent: -18.0, ..Default::default() };
let frame = |r: Rect| {
let mut t = TextObject::point(Point::ZERO, "", style(12.0));
t.kind = TextKind::Area { frame: shapes::rectangle(r) };
t.xf = Affine::IDENTITY;
t
};
let (mut a, mut b) = (frame(Rect::new(20.0, 20.0, 180.0, 80.0)), frame(Rect::new(200.0, 20.0, 360.0, 280.0)));
let text = format!("One\nTwo\n{}\nFour", "The quick brown fox jumps over the lazy dog. ".repeat(4));
let story = vec![TextRun { text, style: style(12.0), inline: None }];
let parts = vectorcraft_text::thread::distribute(FontDb::global(), &[&a, &b], &story, &vec![numbered; 4]);
for (f, part) in [&mut a, &mut b].into_iter().zip(&parts) {
f.runs = part.runs.clone();
f.set_paragraph_styles(part.paras.clone());
f.list_count = part.list_count;
}
let first = a.plain_text();
assert!(first.contains("quick") && !first.ends_with('\n'), "the first frame splits the third paragraph: {first:?}");
let d = doc(vec![a, b]);
let mut drawn: Vec<String> = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
drawn.extend(vectorcraft_text::layout(FontDb::global(), t).markers.into_iter().map(|m| m.text));
}
});
assert!(drawn.contains(&"4.".to_string()), "{drawn:?}");
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
assert_markers_in_place(&d, &back, &drawn.iter().map(String::as_str).collect::<Vec<_>>(), 0.1);
}
/// A number set in oldstyle figures (the first character's OpenType features) is written as its
/// digits, though the character map gives the digits other glyphs. The reopened type has no
/// OpenType features and sets lining figures, so only where each number starts is compared.
#[test]
fn a_number_in_oldstyle_figures_is_written_as_its_digits() {
let number = Some(ParaList::numbered(NumberStyle::Arabic));
let mut t = listed("first\nsecond", &[number, number]);
for r in &mut t.runs {
r.style.features = vec!["onum".into()];
}
let lay = vectorcraft_text::layout(FontDb::global(), &t);
let one = &lay.glyphs[lay.markers[0].glyphs.start];
let face = FontDb::global().face_by_id(one.font_id).unwrap();
assert_ne!(face.glyph_for('1'), one.gid, "an oldstyle 1, not the character map's");
let d = doc(vec![t]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
let all = texts(&back).concat();
assert!(before(&all, "1.", "first") && before(&all, "2.", "second"), "{all:?}");
let got = glyph_chars(&back);
for (origins, digit) in marker_origins(&d).iter().zip(['1', '2']) {
assert!(got.iter().any(|(c, p)| *c == digit && p.distance(origins[0]) < 0.1), "{digit} at {:?}: {got:?}", origins[0]);
}
}
/// A run shifted two billion points off its baseline is left out, as real text or outlines, and
/// the rest of the type is written. On a page 50 points high, the page's y flip puts the shifted
/// run's baseline at -2,147,483,648, which the PDF writer panics on in builds with overflow checks.
+9 -4
View File
@@ -4531,10 +4531,15 @@ after Remove Threading or Release Selection, in a frame converted to point type,
duplicated, copied in the Layers panel, or taken out of a symbol or a placed file). A symbol made of a thread keeps the
numbers in the symbol's art.
The canvas, EPS, PDF and Type › Create Outlines draw the marker; in PDF it is a path, not text. Live SVG text writes the
marker's characters before the paragraph's text, except type on a path with a list, which it writes as outlines with a
warning. DXF writes the marker as a TEXT entity of its own, its characters in the order they are drawn. Legacy `.ai`
writes the paragraph's text without the marker and warns that bulleted and numbered lists are left out.
The canvas, EPS, PDF and Type › Create Outlines draw the marker. PDF writes the marker as text of its own, where the
canvas draws it, and a PDF viewer selects, copies and finds its characters. Each of its glyphs stands for the marker
character that the glyph's font maps to that glyph in its character map. Glyphs that an OpenType feature such as
oldstyle figures replaced stand for the characters left over, in order, when there are as many of those glyphs as
characters left over. A marker glyph that stands for no character, such as the missing-glyph box when no font has a
glyph for it, is drawn as a path. Live SVG text writes the marker's characters before the paragraph's text, except
type on a path with a list, which it writes as outlines with a warning. DXF writes the marker as a TEXT entity of its
own, its characters in the order they are drawn. Legacy `.ai` writes the paragraph's text without the marker and warns
that bulleted and numbered lists are left out.
Type › Bullets and Numbering › Apply Bullets and the canvas context menu's Bullets and Numbering › Apply Bullets run
`type.applyBullets {bullet?}` on the selected type's paragraphs, or on those the Type tool's selection touches. Without
+1
View File
@@ -792,6 +792,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1.
Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([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), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted.
The Type tool starts a list from the characters [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) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `#`, `>` or `+` makes it a bulleted list with the filled dot. The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing.
PDF export writes bullets and numbers as text, in the glyphs and at the places the canvas draws them, in area type, type on a path, right-to-left paragraphs and threaded frames, and a PDF viewer selects, copies and finds them; a marker glyph that stands for no character is drawn as a path.
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`).