SVG import: a nested tspan keeps its direction and unicode-bidi

A <tspan>, <textPath> or <a> inside a <text> 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 <text> 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 <text> 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
This commit is contained in:
dotCooCoo
2026-11-13 22:21:42 -08:00
parent e56d1c7dce
commit a008a0d68e
4 changed files with 371 additions and 15 deletions
+168 -15
View File
@@ -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 `<text>` 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<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 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.
@@ -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 `<text>`, 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 `<tspan>`,
/// `<textPath>` or `<a>` 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
/// `<text>` itself sets the paragraph direction ([`pin_direction`]) and no Character Direction.
fn char_direction(&self, n: XNode) -> (Option<ParaDirection>, 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(&note) {
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<bool> {
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 `<text>` content element holds characters of the text: a shown
/// `<tspan>`, `<textPath>` or `<a>`.
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 `<tspan>`, `<textPath>` and `<a>` 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 `<text>`, 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<Break>,
) -> (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<Option<(f64, f64)>> = 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 `<text>` (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<bool> {
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<TextRun>) -> TextObject {
let mut t = TextObject::point(Point::ZERO, "", CharStyle::default());
+168
View File
@@ -391,6 +391,11 @@ fn rtl_point_text_start_and_end_use_directional_edges() {
(r#"<text x="200" y="60" direction="rtl" font-size="20"><tspan direction="ltr">Wave</tspan></text>"#, 0.0),
(r#"<text x="200" y="60" text-anchor="start" font-size="20">Wave</text>"#, 0.0),
(r#"<text x="200" y="60" direction="ltr" text-anchor="end" font-size="20">Wave</text>"#, 1.0),
// With `unicode-bidi: plaintext`, the direction of the chunk's first strong character.
(r#"<text x="200" y="60" unicode-bidi="plaintext" font-size="20">&#x5D0;&#x5D1;&#x5D2;</text>"#, 1.0),
(r#"<text x="200" y="60" unicode-bidi="plaintext" text-anchor="end" font-size="20">12 &#x5D0;&#x5D1;</text>"#, 0.0),
(r#"<text x="200" y="60" direction="rtl" unicode-bidi="plaintext" font-size="20">Wave</text>"#, 0.0),
(r#"<text x="200" y="60" direction="rtl" unicode-bidi="plaintext" text-anchor="end" font-size="20">12</text>"#, 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<vectorcraft_doc::ParaDirection> {
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 `<tspan>` 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 `<text>` 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#"<text x="20" y="40" font-size="20" direction="{outer}">ab <tspan direction="{inner}" unicode-bidi="{bidi}">1 2</tspan> cd</text>"#
));
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#"<text x="20" y="40" font-size="20">ab <tspan direction="rtl" unicode-bidi="{bidi}">1 2</tspan></text>"#));
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 `<tspan>` 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 `<tspan>` with only a `direction` stays in the outer embedding.
let (t, w) = read(r#"<text y="40">ab <tspan direction="rtl" unicode-bidi="embed">12 <tspan direction="ltr">34</tspan></tspan> cd</text>"#);
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#"<text y="40">ab <tspan direction="rtl" unicode-bidi="embed">12 <tspan direction="ltr" unicode-bidi="inherit">34</tspan></tspan></text>"#,
);
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 `<text>` or a group is not passed on to the `<tspan>`.
let (t, _) = read(r#"<g unicode-bidi="embed"><text y="40" unicode-bidi="embed">ab <tspan direction="rtl">12</tspan></text></g>"#);
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 `<text>`.
for body in [
r#"<style>.e{unicode-bidi:embed}</style><text y="40" direction="rtl">ab <tspan class="e">12</tspan></text>"#,
r#"<text y="40" direction="rtl">ab <tspan style="unicode-bidi: embed">12</tspan></text>"#,
] {
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##"<defs><path id="p" d="M20 100 H380"/></defs><text><textPath href="#p">ab <tspan direction="ltr" unicode-bidi="embed">12</tspan> <a href="https://example.com/"><tspan direction="rtl" unicode-bidi="embed">34</tspan></a></textPath></text>"##,
);
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#"<text id="t" y="40">ab <tspan direction="{dir}" unicode-bidi="{bidi}">{text}</tspan> <tspan direction="{dir}" unicode-bidi="{bidi}">{text}</tspan></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#"<tspan display="none">&#x5D0;&#x5D1;</tspan>ab"#.to_string(), "ab", Ltr),
(format!("<tspan>{far}</tspan>"), "\u{5D0}", Rtl),
(format!("<tspan>\n</tspan>12 <tspan>{far}</tspan>"), "\u{5D0}", Rtl),
] {
let (d, _) =
import_with_report(&svg(&format!(r#"<text y="40">x <tspan direction="ltr" unicode-bidi="plaintext">{body}</tspan></text>"#))).unwrap();
let t = only_text(&d);
assert_eq!(char_direction_of(t, part), Some(want), "{body}: {:?}", t.runs);
}
// `plaintext` on the `<text>`: each paragraph runs in the direction of its first strong character.
for (body, want) in [
("<text y=\"40\" unicode-bidi=\"plaintext\">12 \u{5D0}\u{5D1} cd</text>", vec![Some(Rtl)]),
(r#"<text y="40" direction="rtl" unicode-bidi="plaintext">12 ab</text>"#, vec![Some(Ltr)]),
(
"<text unicode-bidi=\"plaintext\"><tspan x=\"10\" y=\"20\">ab</tspan><tspan x=\"10\" dy=\"40\">\u{5D0}\u{5D1} cd</tspan></text>",
vec![Some(Ltr), Some(Rtl)],
),
// A paragraph without a strong character runs left to right.
(
"<text unicode-bidi=\"plaintext\"><tspan x=\"10\" y=\"20\">\u{5D0}\u{5D1}</tspan><tspan x=\"10\" dy=\"40\">1-2</tspan></text>",
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#"<text unicode-bidi="plaintext" font-size="20"><tspan x="200" y="40">&#x5D0;&#x5D1;</tspan><tspan x="100" y="80">ab &#x5D0;&#x5D1; cd</tspan><tspan x="300">ef</tspan></text>"#,
))
.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 `<text>` keeps its paragraph direction.
let (d, w) = import_with_report(&svg(r#"<text y="40" direction="rtl" unicode-bidi="bidi-override">ab</text>"#)).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]
+34
View File
@@ -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]
+1
View File
@@ -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 `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
SVG import places right-to-left point type and type on a path as [SVG 2's `text-anchor`](https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties) says: with `direction: rtl`, `start` puts the right edge of the text at its position (on a path, the text ends at `startOffset`) and `end` puts the left edge there. Each line and each absolutely placed chunk is anchored by the `text-anchor` and `direction` of the element holding its first character, a `<tspan>`'s own included, and each line starts at its own `x`. An absolutely placed chunk is anchored by the left and right edges of its glyphs as they are drawn, so a chunk of several right-to-left characters, whose first character is drawn at its right, sits on its anchor too. A letter-spaced right-to-left line keeps the tracking of all its letters inside the advance it anchors (`rtl_point_text_start_and_end_use_directional_edges`, `rtl_type_on_a_path_starts_at_its_directional_edge`, `rtl_letter_spaced_lines_stay_on_their_anchor`, `rtl_lines_use_each_chunk_first_character_direction_and_anchor`, `each_line_starts_at_its_own_x`, `absolutely_placed_chunks_use_their_own_rtl_anchor` and `absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edges` in `crates/svg/tests/it/import_text.rs`).
SVG import reads the bidi embedding of a `<tspan>`, `<textPath>` or `<a>` inside a `<text>` as [SVG 1.1's bidi section](https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality) describes: with `unicode-bidi: embed` its characters take its `direction` as their Character Direction, an element with `normal` stays in its parent's embedding, and the `<text>` keeps its own paragraph direction, so a run that SVG export writes with `direction` and `unicode-bidi="embed"` opens with its Character Direction again. Character Direction holds one level of embedding, 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. With `unicode-bidi: plaintext` on the `<text>`, each of its paragraphs runs, and each chunk is anchored, in the direction of its first strong character (`a_tspan_bidi_embedding_becomes_its_character_direction`, `unicode_bidi_is_not_inherited_and_reads_from_the_cascade` and `unicode_bidi_values_the_text_model_cannot_hold_are_reported` in `crates/svg/tests/it/import_text.rs`; `character_direction_reads_back` in `crates/svg/tests/it/text_layout.rs`).
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports.
At a precision of 9 decimals or fewer, SVG export writes each number from an integer when the integer rounds it as the decimal formatter does; the formatter still writes values halfway between two written numbers and values too large for the integer. Exporting one path of 200,000 anchors takes a median of 43.4 ms instead of 167.0 ms on Windows 11, and 20,000 rectangles 81.4 ms instead of 141.4 ms, with the same SVG (`crates/svg/src/lib.rs`).