Character: Character Direction sets the selected characters left to right or right to left

Squashed from #3436 onto main.
This commit is contained in:
bflatastic
2026-10-22 04:18:35 +07:00
parent 0ac1f133b0
commit 34cde7c3db
37 changed files with 361 additions and 21 deletions
+25 -1
View File
@@ -104,6 +104,12 @@ pub struct CharStyle {
/// their line line up with it.
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
pub char_align: CharAlign,
/// Character Direction (Character panel menu, Middle Eastern options): these characters are
/// set left to right or right to left inside their paragraph, each stretch of them a Unicode
/// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives
/// them.
#[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")]
pub char_direction: Option<ParaDirection>,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
/// its proportional heights, `vpal`.
@@ -297,6 +303,7 @@ impl Default for CharStyle {
stroke_align: StrokeAlign::Center,
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
char_direction: None,
proportional_metrics: false,
tate_chu_yoko: false,
small_caps: None,
@@ -862,7 +869,8 @@ impl From<ParaStyleFile> for ParaStyle {
}
}
/// A paragraph's base direction ([`ParaStyle::direction`]).
/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set
/// in ([`CharStyle::char_direction`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ParaDirection {
@@ -1906,6 +1914,22 @@ impl crate::Document {
mod tests {
use super::*;
/// Character Direction is saved only when set, and styles written before it existed read as
/// Default.
#[test]
fn character_direction_is_saved_only_when_set() {
let st = CharStyle::default();
assert_eq!(st.char_direction, None);
assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none());
for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] {
let set = CharStyle { char_direction: Some(d), ..CharStyle::default() };
assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().char_direction, None);
}
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
#[test]
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
+6 -1
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -327,6 +327,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"direction",
"leadingModel",
"charAlign",
"charDirection",
"proportionalMetrics",
"tateChuYoko",
"noBreak",
@@ -350,6 +351,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
};
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
let char_align = super::textedit::char_align_param(p, C)?;
let char_direction = super::textedit::char_direction_param(p, C)?;
let digits = super::textedit::digits_param(p, C)?;
let font_axes = super::textedit::font_axes_param(p, C)?;
let mojikumi = match p.get("mojikumi") {
@@ -460,6 +462,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = char_align {
st.char_align = v;
}
if let Some(v) = char_direction {
st.char_direction = v;
}
if let Some(v) = digits {
st.digits = v;
}
+20 -1
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -256,6 +256,7 @@ pub(crate) struct CharChange {
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
char_align: Option<vectorcraft_doc::CharAlign>,
char_direction: Option<Option<vectorcraft_doc::ParaDirection>>,
proportional_metrics: Option<bool>,
tate_chu_yoko: Option<bool>,
no_break: Option<bool>,
@@ -263,6 +264,19 @@ pub(crate) struct CharChange {
font_axes: Option<AxisChange>,
}
/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None`
/// inside: Default).
pub(crate) fn char_direction_param(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_doc::ParaDirection>>> {
use vectorcraft_doc::ParaDirection;
let Some(v) = p.get("charDirection").filter(|v| !v.is_null()) else { return Ok(None) };
Ok(Some(match v.as_str() {
Some("default") => None,
Some("leftToRight") => Some(ParaDirection::LeftToRight),
Some("rightToLeft") => Some(ParaDirection::RightToLeft),
_ => return Err(bad(cmd, "`charDirection` must be \"default\", \"leftToRight\" or \"rightToLeft\"")),
}))
}
/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in.
pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::Digits>> {
use vectorcraft_doc::Digits;
@@ -416,6 +430,7 @@ impl CharChange {
position,
small_caps,
char_align: char_align_param(p, cmd)?,
char_direction: char_direction_param(p, cmd)?,
proportional_metrics: flag("proportionalMetrics"),
tate_chu_yoko: flag("tateChuYoko"),
no_break: flag("noBreak"),
@@ -459,6 +474,7 @@ impl CharChange {
&& self.position.is_none()
&& self.small_caps.is_none()
&& self.char_align.is_none()
&& self.char_direction.is_none()
&& self.proportional_metrics.is_none()
&& self.tate_chu_yoko.is_none()
&& self.no_break.is_none()
@@ -554,6 +570,9 @@ impl CharChange {
if let Some(v) = self.char_align {
st.char_align = v;
}
if let Some(v) = self.char_direction {
st.char_direction = v;
}
if let Some(v) = self.proportional_metrics {
st.proportional_metrics = v;
}
+1
View File
@@ -155,6 +155,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("position", json!(v.position)),
("small_caps", json!(v.small_caps)),
("charAlign", json!(v.char_align)),
("charDirection", json!(v.char_direction)),
("proportionalMetrics", json!(v.proportional_metrics)),
("tateChuYoko", json!(v.tate_chu_yoko)),
("noBreak", json!(v.no_break)),
+41
View File
@@ -1599,6 +1599,47 @@ mod area_tests {
}
}
/// Character Direction (Character panel menu, Middle Eastern options): a character attribute
/// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when
/// set and kept by a native save.
#[test]
fn character_direction_is_a_character_attribute_saved_only_when_set() {
use vectorcraft_doc::ParaDirection;
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": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::<Vec<_>>();
assert_eq!(runs(&s)[0].style.char_direction, None);
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] {
s.execute("text.setFormat", &json!({"charDirection": key})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.char_direction == want));
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key));
}
let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string();
assert!(err.contains("`charDirection` must be"), "{err}");
// Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes).
s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap();
assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none()));
s.execute("edit.redo", &json!({})).unwrap();
// Saved and reopened as it was.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect();
assert_eq!(reopened, set(&s));
// A character style captured from the characters carries it, Default included.
assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight");
assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null));
}
/// Proportional Metrics (#966): a character attribute, saved only when on.
#[test]
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
+18 -4
View File
@@ -1885,9 +1885,11 @@ impl Writer<'_> {
fn text(&mut self, n: &Node, t: &TextObject) {
// Live SVG text is written in logical order from left-aligned pieces: bidirectional text
// (and right-to-left paragraphs) keep their look as outlines until it is written with
// `direction`/`unicode-bidi`.
// `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose
// `direction="rtl"` would anchor their pieces at the right end.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) {
let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft));
if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs {
self.warn("bidirectional text is outlined to preserve shaping and visual order");
return self.text_outlines(n, t);
}
@@ -2035,7 +2037,8 @@ impl Writer<'_> {
if st.rotation != 0.0 {
attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation)));
}
let diff = run_diff(&base.props, self.char_props(st, base.space));
let mut diff = run_diff(&base.props, self.char_props(st, base.space));
diff.extend(direction_props(st));
attrs.push_str(&self.attrs(&diff));
let text = xml_text(&seg.text);
if attrs.is_empty() && fewer {
@@ -2165,7 +2168,8 @@ impl Writer<'_> {
let offset = fmt_num(start * 100.0, 3);
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
for run in &t.runs {
let diff = run_diff(&base, self.char_props(&run.style, space));
let mut diff = run_diff(&base, self.char_props(&run.style, space));
diff.extend(direction_props(&run.style));
let a = self.attrs(&diff);
s.push_str(&format!("<tspan{a}>{}</tspan>", xml_text(&run.text.replace('\n', " "))));
}
@@ -2198,6 +2202,16 @@ fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool {
lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin)
}
/// The `direction` and `unicode-bidi` of a `<tspan>` of characters in style `st`: an embedding in
/// its Character Direction, and nothing without one. Each `<tspan>` of characters with a direction
/// carries them, and the `<text>` does not. Type with a run set Right to Left is written as
/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only.
fn direction_props(st: &CharStyle) -> Props {
let Some(d) = st.char_direction else { return Props::new() };
let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" };
vec![("direction", direction.into()), ("unicode-bidi", "embed".into())]
}
/// What the `<tspan>` of a run whose properties are `run` sets over its `<text>`'s `base`: the
/// properties that differ, and the initial value of each one `base` sets and the run doesn't.
fn run_diff(base: &Props, run: Props) -> Props {
+37
View File
@@ -125,6 +125,43 @@ fn proportional_metrics_write_palt() {
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
}
/// Character Direction: live text writes each run set Left to Right as a bidi embedding
/// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines.
#[test]
fn character_direction_is_written_on_its_runs() {
use vectorcraft_doc::ParaDirection;
let ltr = CharStyle { char_direction: Some(ParaDirection::LeftToRight), ..style(12.0) };
let d = area_doc(vec![run("Due ", style(12.0)), run("21 10 2026", ltr.clone()), run(" ok", style(12.0))], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert_eq!(tags.len(), 3, "{svg}");
assert!(!tags[0].contains("direction") && !tags[2].contains("direction"), "{svg}");
assert!(tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{}", tags[1]);
// Type on a path: on the run's `<tspan>` in the `<textPath>` too.
let mut on_path = TextObject::point(Point::ZERO, "", style(12.0));
on_path.runs = vec![run("Due ", style(12.0)), run("21 10 2026", ltr)];
let mut line = BezPath::new();
line.move_to((20.0, 100.0));
line.line_to((280.0, 100.0));
on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(on_path)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert!(svg.contains("<textPath") && tags.len() == 2, "{svg}");
assert!(!tags[0].contains("direction") && tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{svg}");
// A run set Right to Left is written as outlines, also when its characters are drawn left to
// right (digits inside an embedding stay left to right).
let rtl = CharStyle { char_direction: Some(ParaDirection::RightToLeft), ..style(12.0) };
for date in ["21 10 2026", "2026"] {
let d = area_doc(vec![run("Due ", style(12.0)), run(date, rtl.clone())], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("<text"), "{date}: {svg}");
}
}
/// Digits are written as drawn: the style's numeral system, not the digits that were typed (as
/// All Caps writes capitals).
#[test]
+65 -2
View File
@@ -50,6 +50,23 @@ impl Ctx<'_> {
find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default)
}
/// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges
/// from `r.start` with their direction, in order; neighboring runs of one direction make one
/// stretch.
fn char_directions(&self, r: &Range<usize>) -> Vec<(Range<usize>, ParaDirection)> {
let mut out: Vec<(Range<usize>, ParaDirection)> = Vec::new();
for (rr, st) in &self.runs {
let (a, b) = (rr.start.max(r.start), rr.end.min(r.end));
let Some(d) = st.char_direction.filter(|_| a < b) else { continue };
let (a, b) = (a - r.start, b - r.start);
match out.last_mut() {
Some((prev, pd)) if *pd == d && prev.end == a => prev.end = b,
_ => out.push((a..b, d)),
}
}
out
}
/// Shape paragraph `r` (its bidi resolution `bidi`).
fn shape_para(&self, r: Range<usize>, bidi: Option<&BidiInfo<'_>>) -> Vec<SGlyph> {
let mut v = Vec::with_capacity(r.len());
@@ -1908,7 +1925,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
// its first line) follow its own.
pen.model = para.leading_model;
let text = cx.text;
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr));
let rtl = is_rtl(bidi.as_ref());
let mut sg = cx.shape_para(pr.clone(), bidi.as_ref());
// Thai, Lao, Khmer and Myanmar break lines between words, not at spaces.
@@ -2310,7 +2327,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
let mut shaped = Vec::new();
for pr in paras {
let text = cx.text;
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
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());
shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs));
@@ -2554,6 +2571,52 @@ pub(crate) fn para_bidi(text: &str, direction: Option<ParaDirection>) -> Option<
Some(BidiInfo::new(text, level))
}
/// [`para_bidi`] for a paragraph whose characters in `set` (byte ranges of `text`, in order and not
/// overlapping, from [`Ctx::char_directions`]) have a Character Direction: each range is resolved
/// as a Unicode bidi embedding, as if an LRE or RLE stood before it and a PDF after it (UAX #9 X2,
/// X3, X7). The levels, classes and paragraphs come back for the bytes of `text`. The paragraph's own
/// direction is unchanged: the first strong character is found inside embeddings too (P2).
pub(crate) fn para_bidi_with<'a>(text: &'a str, direction: Option<ParaDirection>, set: &[(Range<usize>, ParaDirection)]) -> Option<BidiInfo<'a>> {
let plain = para_bidi(text, direction);
if set.is_empty() || (plain.is_none() && set.iter().all(|(_, d)| *d == ParaDirection::LeftToRight)) {
// A left-to-right embedding in left-to-right text takes an even level: its order is the same.
return plain;
}
// The text with the embedding controls, and where each byte of `text` is in it.
let mut marked = String::with_capacity(text.len() + set.len() * 6);
let mut at_byte: Vec<usize> = Vec::with_capacity(text.len());
let mut copy = |marked: &mut String, from: usize, to: usize| {
if let Some(s) = text.get(from..to) {
at_byte.extend(marked.len()..marked.len() + s.len());
marked.push_str(s);
}
};
let mut done = 0;
for (r, d) in set {
let (a, b) = (r.start.max(done).min(text.len()), r.end.min(text.len()));
if a >= b {
continue;
}
copy(&mut marked, done, a);
marked.push(if *d == ParaDirection::RightToLeft { '\u{202B}' } else { '\u{202A}' });
copy(&mut marked, a, b);
marked.push('\u{202C}');
done = b;
}
copy(&mut marked, done, text.len());
let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() });
let info = BidiInfo::new(&marked, level);
let back = |i: usize| at_byte.partition_point(|&m| m < i);
let paragraphs =
info.paragraphs.iter().map(|p| unicode_bidi::ParagraphInfo { range: back(p.range.start)..back(p.range.end), level: p.level }).collect();
Some(BidiInfo {
text,
original_classes: at_byte.iter().filter_map(|&i| info.original_classes.get(i).copied()).collect(),
levels: at_byte.iter().filter_map(|&i| info.levels.get(i).copied()).collect(),
paragraphs,
})
}
/// Does the paragraph run right to left ([`para_bidi`])?
pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool {
bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl())
+5 -3
View File
@@ -433,9 +433,10 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
/// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text?
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
/// character style's name, leading, baseline shift, rotation, Character Alignment, the kerning
/// (each glyph takes its run's, see [`shape_segment`]) and the tracking, as long as the tracking
/// keeps or drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]).
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
/// Direction (text is shaped apart where its bidi level changes), the kerning (each glyph takes its
/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard
/// ligatures alike ([`crate::features::ligatures_suppressed_by`]).
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
// Every field is named, so that a new attribute has to be sorted into one group or the other.
let CharStyle {
@@ -469,6 +470,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
position,
small_caps,
char_align: _,
char_direction: _,
proportional_metrics,
tate_chu_yoko,
digits,
+36 -1
View File
@@ -1,8 +1,9 @@
//! Direction, source-cluster and editing regressions; no RTL font assets required.
use super::*;
use kurbo::{Affine, BezPath, Point, Rect, Shape};
use std::ops::Range;
use std::sync::Arc;
use vectorcraft_doc::{CharStyle, TextKind, TextRun};
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextRun};
fn text_layout(s: &str) -> TextLayout {
layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default()))
@@ -88,6 +89,40 @@ fn mixed_text_preserves_latin_and_number_order() {
assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text");
}
/// The characters of `s` in the order a layout of it draws them, left to right, with the bytes
/// `set` given Character Direction `d`.
fn drawn_with_direction(s: &str, set: Range<usize>, d: Option<ParaDirection>) -> String {
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
edit::style_range(&mut t.runs, set.start, set.end, |st| st.char_direction = d);
let l = layout(FontDb::global(), &t);
let mut glyphs: Vec<_> = l.glyphs.iter().collect();
glyphs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect()
}
/// Character Direction Left to Right keeps a date written with spaces in its own order inside a
/// right-to-left paragraph, where the bidi algorithm alone sets its numbers right to left.
#[test]
fn left_to_right_characters_keep_a_date_in_order_in_right_to_left_text() {
let s = "שלום 21 10 2026";
let date = s.find('2').unwrap_or_default()..s.len();
assert_eq!(drawn_with_direction(s, date.clone(), None), "2026 10 21 םולש");
assert_eq!(drawn_with_direction(s, date, Some(ParaDirection::LeftToRight)), "21 10 2026 םולש");
}
/// Character Direction Right to Left sets a Hebrew word and the exclamation mark after it right to
/// left inside a left-to-right paragraph, where the bidi algorithm alone leaves the mark after the
/// word in the paragraph's direction.
#[test]
fn right_to_left_characters_set_a_word_and_its_mark_right_to_left_in_left_to_right_text() {
let s = "abc שלום! def";
let word = s.find('ש').unwrap_or_default()..s.find('!').map_or(0, |i| i + 1);
assert_eq!(drawn_with_direction(s, word.clone(), None), "abc םולש! def");
assert_eq!(drawn_with_direction(s, word.clone(), Some(ParaDirection::RightToLeft)), "abc !םולש def");
// Left to Right on the same characters of a left-to-right paragraph draws them as without it.
assert_eq!(drawn_with_direction(s, word, Some(ParaDirection::LeftToRight)), "abc םולש! def");
}
#[test]
fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() {
let s = "שלום";
+1
View File
@@ -2342,6 +2342,7 @@
Proof Setup Настройка на пробен печат
Properties Свойства
Proportional Metrics Пропорционални метрики
Character Direction Посока на знаците
Digits Цифри
Arabic Арабски
Hindi Хинди
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1394,6 +1394,7 @@ lighting Light Světlo
Leading Proklad
No Break Nezalamovat
Proportional Metrics Proporcionální metrika
Character Direction Směr znaků
Digits Číslice
Arabic Arabské
Hindi Hindské
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1312,6 +1312,7 @@ lighting Light Lys
Leading Linjeafstand
No Break Ingen ombrydning
Proportional Metrics Proportionale metrikker
Character Direction Tegnretning
Digits Cifre
Arabic Arabisk
Hindi Hindi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2360,6 +2360,7 @@ mark-style Japanese Japanisch
Proof Setup Proof einrichten
Properties Eigenschaften
Proportional Metrics Proportionale Metrik
Character Direction Zeichenrichtung
Digits Ziffern
Arabic Arabisch
Hindi Hindi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1738,6 +1738,7 @@
Proof Colors Χρώματα δοκιμίου
Proof Setup Ρύθμιση δοκιμίου
Properties Ιδιότητες
Character Direction Κατεύθυνση χαρακτήρων
Digits Ψηφία
Punctuation Στίξη
Puppet Warp Παραμόρφωση με σημεία ελέγχου
1 # Ελληνικά (el) — VectorCraft
1738
1739
1740
1741
1742
1743
1744
+1
View File
@@ -2394,6 +2394,7 @@
Proof Setup Ajuste de prueba
Properties Propiedades
Proportional Metrics Métrica proporcional
Character Direction Dirección de caracteres
Digits Dígitos
Arabic Árabes
Hindi Hindi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1369,6 +1369,7 @@ lighting Light Valo
Leading Riviväli
No Break Ei katkoa
Proportional Metrics Suhteelliset metriikat
Character Direction Merkkien suunta
Digits Numerot
Arabic Arabia
Hindi Hindi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2391,6 +2391,7 @@
Proof Setup Format d’épreuve
Properties Propriétés
Proportional Metrics Métrique proportionnelle
Character Direction Direction des caractères
Digits Chiffres
Arabic Arabes
Hindi Hindi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -982,6 +982,7 @@
Diffuse Glow Szórt ragyogás
Diffuse Glow… Szórt ragyogás…
Diffusion Diffúziós
Character Direction Karakterirány
Digits Számjegyek
Dim Copies to: Másolatok halványítása:
Dim Images to: Képek halványítása:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1312,6 +1312,7 @@ lighting Light Cahaya
Leading Leading
No Break Tanpa pemenggalan
Proportional Metrics Metrik proporsional
Character Direction Arah karakter
Digits Digit
Arabic Arab
Hindi Hindi
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1312
1313
1314
1315
1316
1317
1318
+1
View File
@@ -2425,6 +2425,7 @@ mark-style Japanese Giapponese
Proof Setup Impostazione prova
Properties Proprietà
Proportional Metrics Metrica proporzionale
Character Direction Direzione caratteri
Digits Cifre
Arabic Arabe
Hindi Hindi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -39,6 +39,7 @@
ICF Top/Right 平均字面の上 / 右
ICF Bottom/Left 平均字面の下 / 左
Proportional Metrics プロポーショナルメトリクス
Character Direction 文字の方向
Digits 数字
Arabic アラビア数字
Hindi ヒンディー数字
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1227,6 +1227,7 @@ lighting Light სინათლე
Leading სტრიქონთა დაშორება
No Break გადატანის გარეშე
Proportional Metrics პროპორციული მეტრიკა
Character Direction სიმბოლოების მიმართულება
Digits ციფრები
Arabic არაბული
Hindi ჰინდი
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -35,6 +35,7 @@
ICF Top/Right ICF 위/오른쪽
ICF Bottom/Left ICF 아래/왼쪽
Proportional Metrics 비례 메트릭
Character Direction 문자 방향
Digits 숫자
Arabic 아라비아어
Hindi 힌디어
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1326,6 +1326,7 @@ lighting Light Licht
Leading Regelafstand
No Break Niet afbreken
Proportional Metrics Proportionele metingen
Character Direction Tekenrichting
Digits Cijfers
Arabic Arabisch
Hindi Hindi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1308,6 +1308,7 @@ lighting Light Światło
Leading Interlinia
No Break Bez podziału
Proportional Metrics Metryka proporcjonalna
Character Direction Kierunek znaków
Digits Cyfry
Arabic Arabskie
Hindi Hindi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1976,6 +1976,7 @@
Proof Setup Configuração de Prova
Properties Propriedades
Proportional Metrics Métrica Proporcional
Character Direction Direção de Caracteres
Digits Dígitos
Arabic Arábicos
Hindi Hindi
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2421,6 +2421,7 @@ mark-style Japanese Японский
Proof Setup Настройка цветопробы
Properties Свойства
Proportional Metrics Пропорциональные метрики
Character Direction Направление символов
Digits Цифры
Arabic Арабские
Hindi Хинди
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2342,6 +2342,7 @@
Proof Setup Provtrycksinställningar
Properties Egenskaper
Proportional Metrics Proportionella mått
Character Direction Teckenriktning
Digits Siffror
Arabic Arabiska
Hindi Hindi
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -2417,6 +2417,7 @@ mark-style Japanese Японський
Proof Setup Налаштування кольоропроби
Properties Властивості
Proportional Metrics Пропорційні метрики
Character Direction Напрямок символів
Digits Цифри
Arabic Арабські
Hindi Гінді
Can't render this file because it contains an unexpected character in line 733 and column 40.
@@ -55,6 +55,7 @@ Text options: {e}
`background` must be a colour
`burasagari` must be "none", "standard" or "forced"
`charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom"
`charDirection` must be "default", "leftToRight" or "rightToLeft"
`color` needs a `rule` to make the theme from
`colors` is empty
`columnType` is vertical, uniform, repeating or sliding
+1
View File
@@ -876,6 +876,7 @@
Differences from {preset}: Khác biệt so với {preset}:
Diffuse Glow Ánh sáng khuếch tán
Diffuse Glow… Ánh sáng khuếch tán…
Character Direction Hướng ký tự
Digits Chữ số
Dim Copies to: Làm mờ bản sao đến:
Dim Images to: Làm mờ ảnh đến:
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
876
877
878
879
880
881
882
+1
View File
@@ -2298,6 +2298,7 @@ font-script Japanese 汉字
Proof Setup 校样设置
Properties 属性
Proportional Metrics 比例度量
Character Direction 字符方向
Digits 数字
Arabic 阿拉伯数字
Hindi 印地数字
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3172,6 +3172,7 @@ font-script Japanese 漢字
Proof Setup 校樣設定
Properties 內容
Proportional Metrics 比例度量
Character Direction 字元方向
Digits 數字
Arabic 阿拉伯數字
Hindi 印地數字
Can't render this file because it contains an unexpected character in line 6 and column 47.
+63 -8
View File
@@ -712,6 +712,24 @@ fn style_toggle(ui: &mut Ui, g: Glyph, tip: &str, on: bool) -> bool {
resp.clicked()
}
/// The Character Direction submenu: Default, Left to Right and Right to Left set the characters the
/// panel acts on ([`char_cmd`]) as the bidi algorithm sets them, or left to right or right to left
/// inside their paragraph. The direction those characters share is checked, and none is checked
/// when they differ.
fn char_direction_items(app: &mut VectorcraftApp, ui: &mut Ui) {
use vectorcraft_doc::ParaDirection;
let direction = shared(app, |s| s.char_direction);
for (label, d, key) in [
(tl!("Default"), None, "default"),
(tl!("Left to Right"), Some(ParaDirection::LeftToRight), "leftToRight"),
(tl!("Right to Left"), Some(ParaDirection::RightToLeft), "rightToLeft"),
] {
if menu_item(ui, label, true, direction == Some(d)) {
format(app, json!({"charDirection": key}));
}
}
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let st = text_style(app);
let has = st.is_some();
@@ -795,6 +813,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
});
ui.add_enabled_ui(has, |ui| {
ui.menu_button(format!(" {}", tl!("Character Direction")), |ui| char_direction_items(app, ui));
});
ui.separator();
}
// No Break: the selected characters stay on one line.
@@ -847,8 +868,9 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool {
/// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and
/// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps
/// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline
/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off and the Default digits, whether
/// or not Show East Asian Options and Show Indic Options are on.
/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off, the Default character
/// direction and the Default digits, whether or not Show East Asian Options and Show Indic Options
/// are on.
fn reset_panel(app: &mut VectorcraftApp) {
let d = CharStyle::default();
style(app, json!({"tracking": 0, "leading": "auto"}));
@@ -857,7 +879,8 @@ fn reset_panel(app: &mut VectorcraftApp) {
json!({
"kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false,
"allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align,
"proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "digits": d.digits
"proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "charDirection": "default",
"digits": d.digits
}),
);
}
@@ -1292,6 +1315,36 @@ mod tests {
assert!(shown(&mut app));
}
/// Character Direction is in the panel menu with the Middle Eastern (Indic) options, beside
/// Digits. Its submenu checks the direction the characters the panel acts on share: the
/// selected type's, or the range the Type tool has selected. It checks no item when they differ.
#[test]
fn character_direction_checks_the_direction_of_the_selection() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Direction");
assert!(!shown(&mut app));
app.session.prefs.show_indic_options = true;
assert!(shown(&mut app));
let checked = |app: &mut VectorcraftApp| {
let text = crate::tests_labels::painted_text(app, char_direction_items);
text.lines().filter_map(|l| l.strip_prefix("✓ ")).map(str::to_string).collect::<Vec<_>>()
};
assert_eq!(checked(&mut app), ["Default"]);
app.run("text.setRangeStyle", json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap();
// The object's characters differ: nothing is checked.
assert!(checked(&mut app).is_empty());
// The Type tool's selection: the date, then the Hebrew word.
app.select_tool("type");
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
app.session.set_tool_option("select", &json!({"start": 9, "end": 19}));
assert_eq!(checked(&mut app), ["Left to Right"]);
app.session.set_tool_option("select", &json!({"start": 0, "end": 8}));
assert_eq!(checked(&mut app), ["Default"]);
}
/// No Break is in the panel menu, and choosing it marks the selected characters.
#[test]
fn no_break_menu_item_toggles_the_selected_characters() {
@@ -1321,8 +1374,8 @@ mod tests {
/// Reset Panel sets every character attribute the panel shows, except the font family, style
/// and size, back to its default (Small Caps, the position, Character Alignment, Proportional
/// Metrics, Tate-chu-yoko, No Break and Digits included), in the selected type and in the text
/// the Type tool has selected while it edits.
/// Metrics, Tate-chu-yoko, No Break, Character Direction and Digits included), in the selected
/// type and in the text the Type tool has selected while it edits.
#[test]
fn reset_panel_resets_every_attribute_but_the_font() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -1350,6 +1403,7 @@ mod tests {
s.proportional_metrics != d.proportional_metrics,
s.tate_chu_yoko != d.tate_chu_yoko,
s.no_break != d.no_break,
s.char_direction != d.char_direction,
s.digits != d.digits,
]
};
@@ -1359,16 +1413,17 @@ mod tests {
json!({
"id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15,
"underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript",
"charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, "digits": "hindi"
"charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true,
"charDirection": "rightToLeft", "digits": "hindi"
}),
)
.unwrap();
};
let plain = runs(&app)[0].1.clone();
assert_eq!(differs(&plain, &CharStyle::default()), [false; 17]);
assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]);
custom(&mut app);
let styled = runs(&app)[0].1.clone();
assert_eq!(differs(&styled, &CharStyle::default()), [true; 17]);
assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]);
app.run("select.set", json!({"ids": [id]})).unwrap();
reset_panel(&mut app);
assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]);
+19
View File
@@ -4144,6 +4144,25 @@ explicitly, `{}` for none. Each command is one undo step.
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"fontAxes":{"wght":650,"wdth":87.5}}}}
```
## Character Direction
`text.setFormat {charDirection: "default"|"leftToRight"|"rightToLeft"}` (the selected type, or `ids`) and
`text.setRangeStyle {id, start, end, charDirection}` (a range) set the direction of the characters inside their
paragraph, as the Character panel menu's Character Direction does with the Middle Eastern (Indic) options shown. The bidi
algorithm (UAX #9) resolves each stretch of characters with a direction as an embedding in that direction, as if an LRE
or RLE stood before it and a PDF after it: `leftToRight` keeps a date written with spaces, such as `21 10 2026`, in its
order inside a right-to-left paragraph, and `rightToLeft` sets a Hebrew or Arabic word and the punctuation after it right
to left inside a left-to-right paragraph. Characters keep their own strong direction inside an embedding: Latin letters
set Right to Left still read left to right. `default` leaves the characters to the bidi algorithm. The paragraph's own
direction is unchanged. SVG export writes type with a character drawn right to left, or with a run set Right to Left,
as outlines; live SVG text writes `direction="ltr"` and `unicode-bidi="embed"` on each run set Left to Right. Character
Direction is Default until set and is saved only when set, and character styles captured from type carry it. Each
command is one undo step.
```json
{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":9,"end":19,"charDirection":"leftToRight"}}}
```
## Digits
`text.setFormat {digits: "default"|"arabic"|"hindi"|"farsi"}` (the selected type, or `ids`) and `text.setRangeStyle {id,
+1
View File
@@ -683,6 +683,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 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`).
With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point.