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 `