From a008a0d68e0d077566779db634210bab1608d557 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:21:42 -0800 Subject: [PATCH] SVG import: a nested tspan keeps its direction and unicode-bidi A , or inside a whose unicode-bidi is embed now gives its characters its direction as their Character Direction. An element with normal stays in its parent's embedding, unicode-bidi is not inherited (inherit takes the parent's value), and the keeps its own paragraph direction. A run that SVG export writes with direction and unicode-bidi="embed" now opens with its Character Direction again. Character Direction holds one level of embedding and no override or isolation, so bidi-override, isolate and isolate-override are imported as embeddings in their direction, plaintext as an embedding in the direction of its first strong character, and an embedding inside another as an embedding of its own, each with an import warning. unicode-bidi: plaintext on the runs and anchors each paragraph in the direction of its first strong character. https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality https://www.w3.org/TR/css-writing-modes-3/#unicode-bidi --- crates/svg/src/import/text.rs | 183 ++++++++++++++++++++++++++--- crates/svg/tests/it/import_text.rs | 168 ++++++++++++++++++++++++++ crates/svg/tests/it/text_layout.rs | 34 ++++++ docs/target-app-parity.md | 1 + 4 files changed, 371 insertions(+), 15 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 419e73a385..1a221a69f1 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -110,8 +110,25 @@ struct ElemStyle { word_spacing: f64, /// `paint-order` paints the stroke before the fill. stroke_first: bool, + /// What its characters' Character Direction ([`Ctx::elem`]) leaves out of the file's + /// `unicode-bidi`, for the import's warnings. + bidi_notes: Vec<&'static str>, } +/// An override (`unicode-bidi="bidi-override"`) read as an embedding. +const BIDI_OVERRIDE: &str = "unicode-bidi=\"bidi-override\" imported as an embedding; directional override is not supported"; +/// An isolate (`unicode-bidi="isolate"`) read as an embedding. +const BIDI_ISOLATE: &str = "unicode-bidi=\"isolate\" imported as an embedding; isolation is not supported"; +/// `unicode-bidi="isolate-override"` read as an embedding. +const BIDI_ISOLATE_OVERRIDE: &str = "unicode-bidi=\"isolate-override\" imported as an embedding; isolation and override are not supported"; +/// `unicode-bidi="plaintext"` read as an embedding in the direction of its first strong character. +const BIDI_PLAINTEXT: &str = + "unicode-bidi=\"plaintext\" imported as an embedding in the direction of its first strong character; isolation is not supported"; +/// An embedding inside another, kept as an embedding of its own. +const BIDI_NESTED: &str = "a unicode-bidi embedding inside another imported one level deep; its characters may be ordered differently"; +/// An override on the `` itself, which keeps only its paragraph direction. +const BIDI_TEXT_OVERRIDE: &str = "unicode-bidi override on the text imported as its paragraph direction; directional override is not supported"; + /// One character of a text and the adjustments its position needs. struct Cell { ch: char, @@ -694,6 +711,9 @@ struct Ctx { sizes: RefCell>, weights: RefCell>, elems: RefCell>>, + /// Whether the first strong character of each element read so far is right to left + /// ([`Self::first_strong_rtl`]); `None` where it holds none. + strong: RefCell>>, /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has /// no usable one. The styles of a `` instance share it, so each path is found and parsed /// once however many `` elements show its text. @@ -716,6 +736,7 @@ impl Ctx { sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), + strong: RefCell::default(), paths: Rc::default(), }; let root = xml.root_element(); @@ -737,6 +758,7 @@ impl Ctx { sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), + strong: RefCell::default(), paths: self.paths.clone(), } } @@ -915,6 +937,8 @@ impl Ctx { }; let parent = if n.tag_name().name() == "text" { 0.0 } else { n.parent_element().map_or(0.0, |p| self.elem(p).style.baseline_shift) }; st.baseline_shift = parent + own; + let (char_direction, bidi_notes) = self.char_direction(n); + st.char_direction = char_direction; if self.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") { (st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None); } @@ -922,11 +946,93 @@ impl Ctx { let order = self.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; let at = |k| order.iter().position(|o| *o == k); let stroke_first = at(svgtypes::PaintOrderKind::Stroke) < at(svgtypes::PaintOrderKind::Fill); - let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first }); + let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first, bidi_notes }); self.elems.borrow_mut().insert(n.id(), e.clone()); e } + /// The computed `unicode-bidi` of element `n`, in lowercase. It is not inherited: `normal` + /// unless `n` sets it, and its parent's value for `inherit`. + fn unicode_bidi(&self, n: XNode) -> String { + let mut at = Some(n); + while let Some(a) = at { + match self.css.own(a, "unicode-bidi").map(|v| v.trim().to_ascii_lowercase()) { + Some(v) if v == "inherit" => at = self.parent(a), + Some(v) => return v, + None => break, + } + } + "normal".into() + } + + /// The Character Direction of the characters of element `n` inside a ``, and what it + /// leaves out (SVG 1.1 §10.7.4, + /// https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality, and CSS Writing + /// Modes 3 §2.2, https://www.w3.org/TR/css-writing-modes-3/#unicode-bidi). A ``, + /// `` or `` whose `unicode-bidi` opens an embedding gives its characters an + /// embedding in its `direction`; one that opens none (`normal`) leaves them in its parent's. + /// The text model holds one level of embedding and no override or isolation: overrides and + /// isolates become embeddings, `plaintext` an embedding in the direction of its first strong + /// character, and an embedding inside another an embedding of its own, each with a note. The + /// `` itself sets the paragraph direction ([`pin_direction`]) and no Character Direction. + fn char_direction(&self, n: XNode) -> (Option, Vec<&'static str>) { + let name = n.tag_name().name(); + let bidi = self.unicode_bidi(n); + if !matches!(name, "tspan" | "textPath" | "a") { + let notes = + if name == "text" && matches!(bidi.as_str(), "bidi-override" | "isolate-override") { vec![BIDI_TEXT_OVERRIDE] } else { vec![] }; + return (None, notes); + } + let parent = n.parent_element().map(|p| self.elem(p)); + let inherited = parent.as_ref().and_then(|p| p.style.char_direction); + let mut notes = parent.map_or_else(Vec::new, |p| p.bidi_notes.clone()); + let rtl = |rtl: bool| Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }); + let own = || rtl(self.prop(n, "direction").is_some_and(|d| d.trim() == "rtl")); + let (dir, note) = match bidi.as_str() { + "embed" => (own(), None), + "bidi-override" => (own(), Some(BIDI_OVERRIDE)), + "isolate" => (own(), Some(BIDI_ISOLATE)), + "isolate-override" => (own(), Some(BIDI_ISOLATE_OVERRIDE)), + "plaintext" => (rtl(self.first_strong_rtl(n) == Some(true)), Some(BIDI_PLAINTEXT)), + // `normal`, and a value CSS ignores. + _ => return (inherited, notes), + }; + for note in note.into_iter().chain(inherited.map(|_| BIDI_NESTED)) { + if !notes.contains(¬e) { + notes.push(note); + } + } + (dir, notes) + } + + /// Whether the first strong character (UAX #9 P2) of the characters [`Self::collect`] reads + /// from element `n` is right to left; `None` when they hold none. Each element is read once. + fn first_strong_rtl(&self, n: XNode) -> Option { + if let Some(s) = self.strong.borrow().get(&n.id()) { + return *s; + } + let s = n.children().find_map(|c| { + if c.is_text() { + text_first_strong_rtl(c.text().unwrap_or("")) + } else if self.is_inline_child(c) { + self.first_strong_rtl(c) + } else { + None + } + }); + self.strong.borrow_mut().insert(n.id(), s); + s + } + + /// Whether child `c` of a `` content element holds characters of the text: a shown + /// ``, `` or ``. + fn is_inline_child(&self, c: XNode) -> bool { + c.is_element() + && c.tag_name().namespace() == Some(SVG_NS) + && matches!(c.tag_name().name(), "tspan" | "textPath" | "a") + && self.css.own(c, "display").as_deref() != Some("none") + } + /// Characters of `el` and its ``, `` and `` children, with white space /// handled like `xml:space` (newlines and tabs become spaces; runs of spaces collapse unless /// preserved). @@ -943,11 +1049,7 @@ impl Ctx { } out.chars.push((ch, el)); } - } else if c.is_element() - && c.tag_name().namespace() == Some(SVG_NS) - && matches!(c.tag_name().name(), "tspan" | "textPath" | "a") - && self.css.own(c, "display").as_deref() != Some("none") - { + } else if self.is_inline_child(c) { if c.tag_name().name() == "textPath" { out.path.get_or_insert(c); } @@ -1234,6 +1336,16 @@ impl Ctx { } }) .collect(); + // What the Character Direction leaves out of the file's `unicode-bidi`, once for the text. + let mut bidi_notes: Vec<&str> = vec![]; + for (el, _) in cs.spans.iter().filter(|(_, r)| !r.is_empty() && r.start < n) { + for note in &self.elem(*el).bidi_notes { + if !bidi_notes.contains(note) { + bidi_notes.push(note); + } + } + } + warnings.extend(bidi_notes.into_iter().map(|note| format!("{label}: {note}"))); let under = stroke_under(&mut cells, label, warnings); let path = cs.path.and_then(|tp| match self.text_path(xml, tp) { @@ -1244,7 +1356,16 @@ impl Ctx { } }); let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); - let rtl = self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl"); + // With `unicode-bidi: plaintext`, the text runs in the direction of its first strong + // character, and each paragraph in that of its own ([`pin_first_strong`]). + let plaintext = self.unicode_bidi(t) == "plaintext"; + let rtl = if plaintext { self.first_strong_rtl(t) == Some(true) } else { self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") }; + let pin = |obj: &mut TextObject| { + pin_direction(obj, rtl); + if plaintext { + pin_first_strong(obj); + } + }; // Where the chunk starting at character `i` sits on its anchor: 0 its left (or top) edge, // 0.5 its middle, 1 its right (or bottom) edge; on a path, its edge toward the path's // start, its middle or its edge toward the path's end. In horizontal type `start` and @@ -1258,7 +1379,15 @@ impl Ctx { Some("end") => 1.0, _ => 0.0, }; - if !vertical && self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") { 1.0 - a } else { a } + // With `unicode-bidi: plaintext` on the ``, a chunk runs in the direction of its + // first strong character, left to right without one ([`pin_first_strong`]). + let chunk_rtl = || { + let end = (i + 1..n).find(|&j| x[j].is_some() || y[j].is_some()).unwrap_or(n); + let chunk: String = cs.chars[i..end].iter().map(|c| c.0).collect(); + text_first_strong_rtl(&chunk) == Some(true) + }; + let rtl = if plaintext { chunk_rtl() } else { self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") }; + if !vertical && rtl { 1.0 - a } else { a } }; let anchor = anchor_of(0); self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings); @@ -1306,7 +1435,7 @@ impl Ctx { obj.xf = Affine::IDENTITY; } else { let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]); - let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, rtl, origin.x, &mut breaks); + let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks); obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); // Lines anchored differently or at different x (paragraphs aligned or indented @@ -1332,7 +1461,7 @@ impl Ctx { if !para_styles.is_empty() { obj.set_paragraph_styles(para_styles); } - pin_direction(&mut obj, rtl); + pin(&mut obj); keep_reader_anchors(&mut obj); // The placeholder covers the text (laid out when a paint server needs its bounding box). let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default(); @@ -1437,9 +1566,9 @@ fn leading_tolerance(spacing: f64) -> f64 { /// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the /// justification, the x of the first line's left (or anchor) edge and each line's first /// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk -/// starting at a character sits on its anchor, and `rtl` whether the text's `direction` is `rtl` -/// ([`pin_direction`]). When characters are placed on their own, every line is left-aligned and -/// its anchor x is its left edge. +/// starting at a character sits on its anchor, and `pin` sets the paragraph directions of the +/// text ([`pin_direction`]). When characters are placed on their own, every line is left-aligned +/// and its anchor x is its left edge. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -1448,7 +1577,7 @@ fn lines( dx: &[f64], dy: &[f64], anchor_of: &dyn Fn(usize) -> f64, - rtl: bool, + pin: &dyn Fn(&mut TextObject), x0: f64, breaks: &mut Vec, ) -> (Justify, f64, Vec<(usize, f64, f64)>) { @@ -1519,7 +1648,7 @@ fn lines( for round in 0..4 { let (runs, bytes) = assemble(cells, breaks); let mut text = with_runs(runs.0); - pin_direction(&mut text, rtl); + pin(&mut text); let (pens, ends) = pens(&measure(&text), &bytes); // The edges of each line's first chunk, found once a round. let first: Vec> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect(); @@ -1621,6 +1750,30 @@ fn pin_direction(obj: &mut TextObject, rtl: bool) { } } +/// `unicode-bidi: plaintext` on a `` (CSS Writing Modes 3 §2.2, +/// https://www.w3.org/TR/css-writing-modes-3/#unicode-bidi): each paragraph of `obj` is pinned to +/// the direction of its first strong character, and left to right without one (UAX #9 P2, P3). +fn pin_first_strong(obj: &mut TextObject) { + let text = obj.plain_text(); + let mut styles = obj.paragraph_styles(); + for (para, style) in text.split('\n').zip(styles.iter_mut()) { + let rtl = text_first_strong_rtl(para) == Some(true); + style.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }); + } + obj.set_paragraph_styles(styles); +} + +/// Whether the first strong character (UAX #9 P2) of `text` is right to left; `None` when it holds +/// none. Paragraph separators count as spaces, so all of `text` is searched. +fn text_first_strong_rtl(text: &str) -> Option { + let line: String = text.chars().map(|c| if matches!(c, '\n' | '\r' | '\u{1C}'..='\u{1E}' | '\u{85}' | '\u{2029}') { ' ' } else { c }).collect(); + if vectorcraft_text::paragraph_is_rtl(&line, None) { + return Some(true); + } + // Without a strong character of its own, a right-to-left letter after it is the first one. + (!vectorcraft_text::paragraph_is_rtl(&format!("{line}\u{5D0}"), None)).then_some(false) +} + /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 92747cd05a..472d7517e4 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -391,6 +391,11 @@ fn rtl_point_text_start_and_end_use_directional_edges() { (r#"Wave"#, 0.0), (r#"Wave"#, 0.0), (r#"Wave"#, 1.0), + // With `unicode-bidi: plaintext`, the direction of the chunk's first strong character. + (r#"אבג"#, 1.0), + (r#"12 אב"#, 0.0), + (r#"Wave"#, 0.0), + (r#"12"#, 1.0), ] { let d = open(body); let (left, right) = line_edges(only_text(&d))[0]; @@ -464,6 +469,169 @@ fn rtl_lines_use_each_chunk_first_character_direction_and_anchor() { assert!(close((e[3].0 + e[3].1) / 2.0, 200.0, 1e-6), "a middle line is centered on 200: {e:?}"); } +/// The Character Direction of the run holding `part`. +fn char_direction_of(t: &TextObject, part: &str) -> Option { + t.runs.iter().find(|r| r.text.contains(part)).unwrap_or_else(|| panic!("no run holds {part:?}: {:?}", t.runs)).style.char_direction +} + +/// SVG 1.1 §10.7.4: a `` whose `unicode-bidi` is `embed` or `bidi-override` opens a bidi +/// embedding in its `direction`; with `normal`, its `direction` leaves the ordering alone. The +/// embedding becomes the Character Direction of its characters, and the `` keeps its own +/// paragraph direction. https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality +#[test] +fn a_tspan_bidi_embedding_becomes_its_character_direction() { + use vectorcraft_doc::ParaDirection::{LeftToRight as Ltr, RightToLeft as Rtl}; + let read = |body: &str| { + let (d, w) = import_with_report(&svg(body)).unwrap_or_else(|e| panic!("{e}")); + (only_text(&d).clone(), w) + }; + // (outer direction, inner direction, unicode-bidi, the inner run's Character Direction, a warning) + for (outer, inner, bidi, want, warns) in [ + ("ltr", "rtl", "embed", Some(Rtl), false), + ("rtl", "ltr", "embed", Some(Ltr), false), + ("ltr", "rtl", "bidi-override", Some(Rtl), true), + ("rtl", "ltr", "bidi-override", Some(Ltr), true), + ("ltr", "rtl", "normal", None, false), + ("rtl", "ltr", "normal", None, false), + ("ltr", "rtl", "EMBED", Some(Rtl), false), + ("ltr", "rtl", "sideways", None, false), + ] { + let (t, w) = read(&format!( + r#"ab 1 2 cd"# + )); + let case = format!("{outer} text, {inner} {bidi} tspan"); + assert_eq!(t.plain_text(), "ab 1 2 cd", "{case}: the characters stay in logical order"); + assert_eq!(char_direction_of(&t, "1 2"), want, "{case}: {:?}", t.runs); + assert_eq!(char_direction_of(&t, "ab"), None, "{case}"); + assert_eq!(t.para_at(0).direction == Some(Rtl), outer == "rtl", "{case}: the paragraph keeps the text's direction"); + assert_eq!(w.iter().any(|w| w.contains("bidi-override") && w.contains("not supported")), warns, "{case}: {w:?}"); + if !warns { + assert!(w.is_empty(), "{case}: {w:?}"); + } + } + // In left-to-right text, digits in a right-to-left embedding are drawn right to left. + let order = |bidi: &str| { + let (t, _) = read(&format!(r#"ab 1 2"#)); + let lay = layout(FontDb::global(), &t); + let x = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap_or_else(|| panic!("no glyph at {byte}")); + x(3) < x(5) + }; + assert!(!order("embed"), "an embedding draws 2 before 1"); + assert!(order("normal"), "without one, 1 comes first"); +} + +/// `unicode-bidi` is not inherited: a `` without one stays in its parent's embedding, and +/// `inherit` takes the parent's value. CSS rules and `style` set it as they set any property. +#[test] +fn unicode_bidi_is_not_inherited_and_reads_from_the_cascade() { + use vectorcraft_doc::ParaDirection::{LeftToRight as Ltr, RightToLeft as Rtl}; + let read = |body: &str| { + let (d, w) = import_with_report(&svg(body)).unwrap_or_else(|e| panic!("{e}")); + (only_text(&d).clone(), w) + }; + // An inner `` with only a `direction` stays in the outer embedding. + let (t, w) = read(r#"ab 12 34 cd"#); + assert_eq!((char_direction_of(&t, "12"), char_direction_of(&t, "34"), char_direction_of(&t, "cd")), (Some(Rtl), Some(Rtl), None), "{:?}", t.runs); + assert!(w.is_empty(), "{w:?}"); + // `inherit` opens an embedding of its own, inside the outer one: kept one level deep, with a warning. + let (t, w) = read( + r#"ab 12 34"#, + ); + assert_eq!((char_direction_of(&t, "12"), char_direction_of(&t, "34")), (Some(Rtl), Some(Ltr)), "{:?}", t.runs); + assert!(w.len() == 1 && w[0].contains("inside another"), "{w:?}"); + // A `unicode-bidi` on the `` or a group is not passed on to the ``. + let (t, _) = read(r#"ab 12"#); + assert!(t.runs.iter().all(|r| r.style.char_direction.is_none()), "{:?}", t.runs); + // From a CSS rule and from `style`; `direction` inherits from the ``. + for body in [ + r#"ab 12"#, + r#"ab 12"#, + ] { + let (t, w) = read(body); + assert_eq!(char_direction_of(&t, "12"), Some(Rtl), "{body}: {:?}", t.runs); + assert_eq!(char_direction_of(&t, "ab"), None, "{body}: {:?}", t.runs); + assert!(w.is_empty(), "{body}: {w:?}"); + } + // On a path and in a link too. + let (t, _) = read( + r##"ab 12 34"##, + ); + assert!(matches!(t.kind, TextKind::OnPath { .. })); + assert_eq!((char_direction_of(&t, "12"), char_direction_of(&t, "34")), (Some(Ltr), Some(Rtl)), "{:?}", t.runs); +} + +/// The text model holds embeddings only: isolates and overrides become embeddings in their +/// `direction`, and `plaintext` one in the direction of its first strong character, each with a +/// warning once a text (CSS Writing Modes 3 §2.2, +/// https://www.w3.org/TR/css-writing-modes-3/#unicode-bidi). +#[test] +fn unicode_bidi_values_the_text_model_cannot_hold_are_reported() { + use vectorcraft_doc::ParaDirection::{LeftToRight as Ltr, RightToLeft as Rtl}; + for (bidi, dir, text, want, says) in [ + ("isolate", "rtl", "12", Some(Rtl), "isolation is not supported"), + ("isolate-override", "rtl", "12", Some(Rtl), "isolation and override are not supported"), + ("plaintext", "ltr", "\u{5D0}\u{5D1}", Some(Rtl), "first strong character"), + ("plaintext", "rtl", "12 ab", Some(Ltr), "first strong character"), + ] { + let body = format!( + r#"ab {text} {text}"# + ); + let (d, w) = import_with_report(&svg(&body)).unwrap_or_else(|e| panic!("{e}")); + let t = only_text(&d); + assert_eq!(char_direction_of(t, text), want, "{bidi}: {:?}", t.runs); + assert!(w.len() == 1 && w[0].starts_with("text 't': ") && w[0].contains(&format!("\"{bidi}\"")) && w[0].contains(says), "{bidi}: {w:?}"); + } + // `plaintext` looks for the first strong character among the characters imported, however far in. + let far = format!("{}\u{5D0}\u{5D1}", "1 ".repeat(1500)); + for (body, part, want) in [ + (r#"אבab"#.to_string(), "ab", Ltr), + (format!("{far}"), "\u{5D0}", Rtl), + (format!("\n12 {far}"), "\u{5D0}", Rtl), + ] { + let (d, _) = + import_with_report(&svg(&format!(r#"x {body}"#))).unwrap(); + let t = only_text(&d); + assert_eq!(char_direction_of(t, part), Some(want), "{body}: {:?}", t.runs); + } + // `plaintext` on the ``: each paragraph runs in the direction of its first strong character. + for (body, want) in [ + ("12 \u{5D0}\u{5D1} cd", vec![Some(Rtl)]), + (r#"12 ab"#, vec![Some(Ltr)]), + ( + "ab\u{5D0}\u{5D1} cd", + vec![Some(Ltr), Some(Rtl)], + ), + // A paragraph without a strong character runs left to right. + ( + "\u{5D0}\u{5D1}1-2", + vec![Some(Rtl), Some(Ltr)], + ), + ] { + let (d, w) = import_with_report(&svg(body)).unwrap(); + let t = only_text(&d); + let got: Vec<_> = (0..t.paragraph_styles().len()).map(|i| t.para_at(i).direction).collect(); + assert_eq!(got, want, "{body}"); + assert!(w.is_empty(), "{body}: {w:?}"); + } + // Absolutely placed characters sit on their x in the direction of their own paragraph. + let (d, _) = import_with_report(&svg( + r#"אבab אב cdef"#, + )) + .unwrap(); + let t = only_text(&d); + assert_eq!((t.para_at(0).direction, t.para_at(1).direction), (Some(Rtl), Some(Ltr))); + let lay = layout(FontDb::global(), t); + for (byte, x) in [(5, 100.0), (15, 300.0)] { + let g = lay.glyphs.iter().find(|g| g.byte == byte).unwrap_or_else(|| panic!("no glyph at {byte}")); + let left = (t.xf * g.origin).x; + assert!(close(left, x, 0.05), "the character at {byte} starts at {left}, not {x}"); + } + // An override on the `` keeps its paragraph direction. + let (d, w) = import_with_report(&svg(r#"ab"#)).unwrap(); + assert_eq!(only_text(&d).para_at(0).direction, Some(Rtl)); + assert!(w.len() == 1 && w[0].contains("override on the text"), "{w:?}"); +} + /// A line starts at its own x, also when it is anchored as the line before it is and when another /// line holds absolutely placed characters. #[test] diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index 25d4d27d68..8dd5fe4bac 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -260,6 +260,40 @@ fn character_direction_is_written_on_its_runs() { } } +/// A run set Left to Right keeps its Character Direction when its SVG is opened again, on a line +/// and on a path. +#[test] +fn character_direction_reads_back() { + use vectorcraft_doc::ParaDirection; + let ltr = CharStyle { char_direction: Some(ParaDirection::LeftToRight), ..style(12.0) }; + 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.clone())]; + 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 path_doc = Document::new(300.0, 200.0); + let n = Node::new(path_doc.alloc_id(), NodeKind::Text(Box::new(on_path))); + let l = path_doc.layers[0].id; + path_doc.insert(Some(l), 0, n).unwrap(); + let area = area_doc(vec![run("Due ", style(12.0)), run("21 10 2026", ltr), run(" ok", style(12.0))], 280.0, Justify::Left); + for (name, d) in [("area", area), ("path", path_doc)] { + let svg = export(&d, &ExportOptions::default()); + let back = vectorcraft_svg::import(&svg).unwrap_or_else(|e| panic!("{name}: {e}\n{svg}")); + let mut texts = vec![]; + back.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + texts.push((**t).clone()); + } + }); + assert_eq!(texts.len(), 1, "{name}: {svg}"); + let t = &texts[0]; + let dir = |part: &str| t.runs.iter().find(|r| r.text.contains(part)).map(|r| r.style.char_direction); + assert_eq!(dir("2026"), Some(Some(ParaDirection::LeftToRight)), "{name}: {:?}\n{svg}", t.runs); + assert_eq!(dir("Due"), Some(None), "{name}: {:?}", t.runs); + } +} + /// Digits are written as drawn: the style's numeral system, not the digits that were typed (as /// All Caps writes capitals). #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..a813da35f4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1632,6 +1632,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623). SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `