diff --git a/Cargo.lock b/Cargo.lock index 476af0dc89..f3e85a08a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6823,6 +6823,7 @@ dependencies = [ "skrifa 0.47.0", "svgtypes 0.16.1", "thiserror 2.0.21", + "unicode-bidi", "usvg 0.48.1", "vectorcraft-brush", "vectorcraft-color", diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 7708d23c06..36744c8de5 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -31,6 +31,8 @@ image = { workspace = true } # Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" simplecss = "0.2.2" +# Bidi classes for the first strong character of `unicode-bidi: plaintext` text (MIT OR Apache-2.0; already used by vectorcraft-text). +unicode-bidi = "0.3.18" [dev-dependencies] # Reading embedded font subsets back. diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 1a221a69f1..87e100f1b9 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -711,9 +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 first strong characters of each `` read so far, by the `` + /// ([`Self::first_strong_rtl`]). + 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. @@ -1006,22 +1006,39 @@ impl Ctx { } /// 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. + /// from element `n` is right to left; `None` when they hold none. The characters of the whole + /// `` are read once, as one sequence, so an isolate that opens in one text node and + /// closes in a later one is skipped whole ([`FirstStrong`]). fn first_strong_rtl(&self, n: XNode) -> Option { - if let Some(s) = self.strong.borrow().get(&n.id()) { - return *s; + // The `` (or outermost element) whose characters hold those of `n`. + let mut root = n; + while let Some(p) = root.parent_element().filter(|_| root.tag_name().name() != "text" && self.is_inline_child(root)) { + root = p; } - 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 - } + let strong = self.strong.borrow().get(&root.id()).cloned(); + let strong = strong.unwrap_or_else(|| { + let mut chars = vec![]; + let mut spans = HashMap::new(); + self.inline_chars(root, &mut chars, &mut spans); + let s = Rc::new(FirstStrong::new(&chars, spans)); + self.strong.borrow_mut().insert(root.id(), s.clone()); + s }); - self.strong.borrow_mut().insert(n.id(), s); - s + strong.of(n.id()) + } + + /// Appends the characters of element `n` and of its shown inline children to `chars`, in + /// document order, and the range each of those elements holds to `spans`. + fn inline_chars(&self, n: XNode, chars: &mut Vec, spans: &mut HashMap>) { + let start = chars.len(); + for c in n.children() { + if c.is_text() { + chars.extend(c.text().unwrap_or("").chars()); + } else if self.is_inline_child(c) { + self.inline_chars(c, chars, spans); + } + } + spans.insert(n.id(), start..chars.len()); } /// Whether child `c` of a `` content element holds characters of the text: a shown @@ -1774,6 +1791,56 @@ fn text_first_strong_rtl(text: &str) -> Option { (!vectorcraft_text::paragraph_is_rtl(&format!("{line}\u{5D0}"), None)).then_some(false) } +/// The first strong characters (UAX #9 P2) of the elements of one ``, found for each element +/// in constant time from one pass over the text's characters. An isolate initiator is skipped to +/// its matching PDI (UAX #9 BD9), and one without a matching PDI to the end; paragraph separators +/// count as spaces, as in [`text_first_strong_rtl`]. +struct FirstStrong { + /// The characters each element holds: a range of indices into `next`. + spans: HashMap>, + /// For each character and for the end: the index of the first strong character from there on, + /// isolates skipped, and whether it is right to left; `None` when there is none. + next: Vec>, +} + +impl FirstStrong { + fn new(chars: &[char], spans: HashMap>) -> Self { + use unicode_bidi::BidiClass::{AL, FSI, L, LRI, PDI, R, RLI}; + let class = |c: char| unicode_bidi::bidi_class(c); + // The matching PDI of each isolate initiator. + let mut pdi = vec![None; chars.len()]; + let mut open = vec![]; + for (i, &c) in chars.iter().enumerate() { + match class(c) { + LRI | RLI | FSI => open.push(i), + PDI => { + if let Some(at) = open.pop() { + pdi[at] = Some(i); + } + } + _ => {} + } + } + let mut next = vec![None; chars.len() + 1]; + for (i, &c) in chars.iter().enumerate().rev() { + next[i] = match class(c) { + L => Some((i, false)), + R | AL => Some((i, true)), + LRI | RLI | FSI => pdi[i].and_then(|p| next[p + 1]), + _ => next[i + 1], + }; + } + Self { spans, next } + } + + /// Whether the first strong character of element `n` is right to left; `None` when it holds + /// none. An isolate that opens inside `n` and closes after it hides the rest of `n`. + fn of(&self, n: roxmltree::NodeId) -> Option { + let span = self.spans.get(&n)?; + self.next.get(span.start).copied().flatten().filter(|&(at, _)| at < span.end).map(|(_, rtl)| rtl) + } +} + /// 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 472d7517e4..0bc8c34dfd 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -587,6 +587,12 @@ fn unicode_bidi_values_the_text_model_cannot_hold_are_reported() { (r#"אבab"#.to_string(), "ab", Ltr), (format!("{far}"), "\u{5D0}", Rtl), (format!("\n12 {far}"), "\u{5D0}", Rtl), + // An isolate that opens in one text node and closes in a later one is skipped whole. + ("⁧ab⁩א".to_string(), "\u{5D0}", Rtl), + ("⁧abcd⁩א".to_string(), "\u{5D0}", Rtl), + // An isolate that opens in a nested `plaintext` element and closes after it hides the rest of that element. + (r#"⁧ab⁩א"#.to_string(), "\u{5D0}", Rtl), + (r#"⁧ab⁩א"#.to_string(), "ab", Ltr), ] { let (d, _) = import_with_report(&svg(&format!(r#"x {body}"#))).unwrap();