mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Character: Character Direction sets the selected characters left to right or right to left
Squashed from #3436 onto main.
This commit is contained in:
+25
-1
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "שלום";
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -1738,6 +1738,7 @@
|
||||
Proof Colors Χρώματα δοκιμίου
|
||||
Proof Setup Ρύθμιση δοκιμίου
|
||||
Properties Ιδιότητες
|
||||
Character Direction Κατεύθυνση χαρακτήρων
|
||||
Digits Ψηφία
|
||||
Punctuation Στίξη
|
||||
Puppet Warp Παραμόρφωση με σημεία ελέγχου
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user