diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 3b7df2f42..9a1f8ae43 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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, /// 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 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::(serde_json::to_value(&set).unwrap()).unwrap(), set); + } + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(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() { diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 0db52da4b..d4cccc212 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), 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 { "direction", "leadingModel", "charAlign", + "charDirection", "proportionalMetrics", "tateChuYoko", "noBreak", @@ -350,6 +351,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }; 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 { 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; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index c51c05846..98d31cfb7 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "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, small_caps: Option>, char_align: Option, + char_direction: Option>, proportional_metrics: Option, tate_chu_yoko: Option, no_break: Option, @@ -263,6 +264,19 @@ pub(crate) struct CharChange { font_axes: Option, } +/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` +/// inside: Default). +pub(crate) fn char_direction_param(p: &Value, cmd: &str) -> Result>> { + 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> { 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; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 77984967c..cf3585f59 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -155,6 +155,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("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)), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 89ec47958..67fcccd12 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -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::>(); + 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() { diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index f66f7c024..3bfa57b9e 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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!(""); 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!("{}", 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 `` of characters in style `st`: an embedding in +/// its Character Direction, and nothing without one. Each `` of characters with a direction +/// carries them, and the `` 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 `` of a run whose properties are `run` sets over its ``'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 { diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index d86403749..c186347bc 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -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 `` in the `` 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(" { 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) -> Vec<(Range, ParaDirection)> { + let mut out: Vec<(Range, 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, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); @@ -1908,7 +1925,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], 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], 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) -> 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, set: &[(Range, ParaDirection)]) -> Option> { + 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 = 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()) diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 868592356..1fa96c87a 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -433,9 +433,10 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, 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, diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index c213f09cf..291b6fa8f 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -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, d: Option) -> 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 = "שלום"; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 7bf701b66..29a767b2f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2342,6 +2342,7 @@ Proof Setup Настройка на пробен печат Properties Свойства Proportional Metrics Пропорционални метрики + Character Direction Посока на знаците Digits Цифри Arabic Арабски Hindi Хинди diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4984939f4..2bfd1d4a8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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é diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 994b9a694..5ee3ddcc6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 05bc2310f..7bf36dcc0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 967bec7ae..8523139e4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1738,6 +1738,7 @@ Proof Colors Χρώματα δοκιμίου Proof Setup Ρύθμιση δοκιμίου Properties Ιδιότητες + Character Direction Κατεύθυνση χαρακτήρων Digits Ψηφία Punctuation Στίξη Puppet Warp Παραμόρφωση με σημεία ελέγχου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1836a41af..b71316b6d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 595354306..ee29f245b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b305ddb4d..aeacf5976 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 219a5df90..b6964882c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 73f10a76a..007ed4ae6 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7aeb13c08..69e0d5db7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 10c708f16..b42b86bbf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -39,6 +39,7 @@ ICF Top/Right 平均字面の上 / 右 ICF Bottom/Left 平均字面の下 / 左 Proportional Metrics プロポーショナルメトリクス + Character Direction 文字の方向 Digits 数字 Arabic アラビア数字 Hindi ヒンディー数字 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index fd62f9fe4..cbf19aab2 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1227,6 +1227,7 @@ lighting Light სინათლე Leading სტრიქონთა დაშორება No Break გადატანის გარეშე Proportional Metrics პროპორციული მეტრიკა + Character Direction სიმბოლოების მიმართულება Digits ციფრები Arabic არაბული Hindi ჰინდი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d01bbc447..7b937b263 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -35,6 +35,7 @@ ICF Top/Right ICF 위/오른쪽 ICF Bottom/Left ICF 아래/왼쪽 Proportional Metrics 비례 메트릭 + Character Direction 문자 방향 Digits 숫자 Arabic 아라비아어 Hindi 힌디어 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4165cd1fd..1a17b5339 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index be9ac1abd..4a1deebb3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f0dd3c4df..ce38f89a0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4e09de1d4..0f01f3c51 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2421,6 +2421,7 @@ mark-style Japanese Японский Proof Setup Настройка цветопробы Properties Свойства Proportional Metrics Пропорциональные метрики + Character Direction Направление символов Digits Цифры Arabic Арабские Hindi Хинди diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 095de81f7..54425e4b5 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2342,6 +2342,7 @@ Proof Setup Provtrycksinställningar Properties Egenskaper Proportional Metrics Proportionella mått + Character Direction Teckenriktning Digits Siffror Arabic Arabiska Hindi Hindi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b724b380e..5ef1a9c33 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2417,6 +2417,7 @@ mark-style Japanese Японський Proof Setup Налаштування кольоропроби Properties Властивості Proportional Metrics Пропорційні метрики + Character Direction Напрямок символів Digits Цифри Arabic Арабські Hindi Гінді diff --git a/crates/ui-egui/src/i18n/untranslated_messages.txt b/crates/ui-egui/src/i18n/untranslated_messages.txt index 05f865c83..52694f803 100644 --- a/crates/ui-egui/src/i18n/untranslated_messages.txt +++ b/crates/ui-egui/src/i18n/untranslated_messages.txt @@ -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 diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ceb7fa79d..6dde845a7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 342eb001b..58d9df0b3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2298,6 +2298,7 @@ font-script Japanese 汉字 Proof Setup 校样设置 Properties 属性 Proportional Metrics 比例度量 + Character Direction 字符方向 Digits 数字 Arabic 阿拉伯数字 Hindi 印地数字 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index eb29aae7c..d2133dd04 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3172,6 +3172,7 @@ font-script Japanese 漢字 Proof Setup 校樣設定 Properties 內容 Proportional Metrics 比例度量 + Character Direction 字元方向 Digits 數字 Arabic 阿拉伯數字 Hindi 印地數字 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 97f30f54c..a6959e7c9 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -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::>() + }; + 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())]); diff --git a/docs/mcp.md b/docs/mcp.md index 499c7f803..4c6bf9adb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3a340fe13..4c372c2f5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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.