mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 14:41:52 +00:00
SVG import: plaintext finds its first strong character across tspans
With `unicode-bidi: plaintext`, SVG import looked for the first strong character of an element in each text node on its own and kept the first answer. An isolate that opened in one text node and closed in a later one hid the rest of that node, and the node then answered left to right. For `⁧ab<tspan>⁩א</tspan>` the element ran left to right, where UAX #9 P2 skips the isolate and finds the right-to-left letter after it (https://www.unicode.org/reports/tr9/#P2). The characters of the whole `<text>` are now read once, in order, and each isolate initiator is matched with its PDI (UAX #9 BD9). Each element's first strong character is then found in constant time, so nested `plaintext` elements do not read the same characters again. An isolate that opens inside an element and closes after it hides the rest of that element. vectorcraft-svg takes `unicode-bidi` 0.3.18, which vectorcraft-text already uses, for the bidi classes.
This commit is contained in:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -31,6 +31,8 @@ image = { workspace = true }
|
||||
# Colour / transform / length parsing for the <text> 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.
|
||||
|
||||
@@ -711,9 +711,9 @@ struct Ctx {
|
||||
sizes: RefCell<HashMap<roxmltree::NodeId, f64>>,
|
||||
weights: RefCell<HashMap<roxmltree::NodeId, u16>>,
|
||||
elems: RefCell<HashMap<roxmltree::NodeId, Rc<ElemStyle>>>,
|
||||
/// 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<HashMap<roxmltree::NodeId, Option<bool>>>,
|
||||
/// The first strong characters of each `<text>` read so far, by the `<text>`
|
||||
/// ([`Self::first_strong_rtl`]).
|
||||
strong: RefCell<HashMap<roxmltree::NodeId, Rc<FirstStrong>>>,
|
||||
/// The path each `<textPath>` read so far follows ([`Self::text_path`]); `None` where it has
|
||||
/// no usable one. The styles of a `<use>` instance share it, so each path is found and parsed
|
||||
/// once however many `<use>` 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
|
||||
/// `<text>` 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<bool> {
|
||||
if let Some(s) = self.strong.borrow().get(&n.id()) {
|
||||
return *s;
|
||||
// The `<text>` (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<char>, spans: &mut HashMap<roxmltree::NodeId, Range<usize>>) {
|
||||
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 `<text>` content element holds characters of the text: a shown
|
||||
@@ -1774,6 +1791,56 @@ fn text_first_strong_rtl(text: &str) -> Option<bool> {
|
||||
(!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 `<text>`, 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<roxmltree::NodeId, Range<usize>>,
|
||||
/// 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<Option<(usize, bool)>>,
|
||||
}
|
||||
|
||||
impl FirstStrong {
|
||||
fn new(chars: &[char], spans: HashMap<roxmltree::NodeId, Range<usize>>) -> 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<bool> {
|
||||
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<TextRun>) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "", CharStyle::default());
|
||||
|
||||
@@ -587,6 +587,12 @@ fn unicode_bidi_values_the_text_model_cannot_hold_are_reported() {
|
||||
(r#"<tspan display="none">אב</tspan>ab"#.to_string(), "ab", Ltr),
|
||||
(format!("<tspan>{far}</tspan>"), "\u{5D0}", Rtl),
|
||||
(format!("<tspan>\n</tspan>12 <tspan>{far}</tspan>"), "\u{5D0}", Rtl),
|
||||
// An isolate that opens in one text node and closes in a later one is skipped whole.
|
||||
("⁧ab<tspan>⁩א</tspan>".to_string(), "\u{5D0}", Rtl),
|
||||
("⁧ab<tspan>cd</tspan>⁩א".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#"<tspan unicode-bidi="plaintext">⁧ab</tspan>⁩א"#.to_string(), "\u{5D0}", Rtl),
|
||||
(r#"<tspan unicode-bidi="plaintext">⁧ab</tspan>⁩א"#.to_string(), "ab", Ltr),
|
||||
] {
|
||||
let (d, _) =
|
||||
import_with_report(&svg(&format!(r#"<text y="40">x <tspan direction="ltr" unicode-bidi="plaintext">{body}</tspan></text>"#))).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user