rsvg/
text2.rs

1// ! development file for text2
2use cssparser::Parser;
3use markup5ever::{expanded_name, local_name, ns};
4use pango::IsAttribute;
5use rctree::NodeEdge;
6
7use crate::element::{set_attribute, Element, ElementData, ElementTrait};
8use crate::error::ParseError;
9use crate::layout::FontProperties;
10use crate::length::{Horizontal, Length, NormalizeParams, Vertical};
11use crate::node::{Node, NodeData};
12use crate::parsers::{CommaSeparatedList, Parse, ParseValue};
13use crate::properties::WhiteSpace;
14use crate::session::Session;
15use crate::text::BidiControl;
16use crate::xml;
17use crate::{parse_identifiers, rsvg_log};
18
19/// Type for the `x/y/dx/dy` attributes of the `<text>` and `<tspan>` elements
20///
21/// <https://svgwg.org/svg2-draft/text.html#TSpanAttributes>
22///
23/// Explanation of this type:
24///
25/// * Option - the attribute can be specified or not, so make it optional
26///
27///  `CommaSeparatedList<Length<Horizontal>>` - This type knows how to parse a list of values
28///  that are separated by commas and/or spaces; the values are eventually available as a Vec.
29///
30/// * 1 is the minimum number of elements in the list, so one can have x="42" for example.
31///
32/// * 4096 is an arbitrary limit on the number of length values for each array, as a mitigation
33///   against malicious files which may want to have millions of elements to exhaust memory.
34type OptionalLengthList<N> = Option<CommaSeparatedList<Length<N>, 1, 4096>>;
35
36/// Type for the `rotate` attribute of the `<text>` and `<tspan>` elements
37///
38/// <https://svgwg.org/svg2-draft/text.html#TSpanAttributes>
39///
40/// See [`OptionalLengthList`] for a description of the structure of the type.
41type OptionalRotateList = Option<CommaSeparatedList<f64, 1, 4096>>;
42
43/// Enum for the `lengthAdjust` attribute
44///
45/// <https://svgwg.org/svg2-draft/text.html#LengthAdjustProperty>
46#[derive(Debug, Default, Copy, Clone, PartialEq)]
47enum LengthAdjust {
48    #[default]
49    Spacing,
50    SpacingAndGlyphs,
51}
52
53impl Parse for LengthAdjust {
54    fn parse<'i>(parser: &mut Parser<'i, '_>) -> Result<Self, ParseError<'i>> {
55        Ok(parse_identifiers!(
56            parser,
57            "spacing" => LengthAdjust::Spacing,
58            "spacingAndGlyphs" => LengthAdjust::SpacingAndGlyphs,
59        )?)
60    }
61}
62
63#[allow(dead_code)]
64#[derive(Default)]
65pub struct Text2 {
66    x: OptionalLengthList<Horizontal>,
67    y: OptionalLengthList<Vertical>,
68    dx: OptionalLengthList<Horizontal>,
69    dy: OptionalLengthList<Vertical>,
70    rotate: OptionalRotateList,
71    text_length: Length<Horizontal>,
72    length_adjust: LengthAdjust, // Implemented
73}
74
75// HOMEWORK
76//
77// see text.rs and how it implements set_attributes() for the Text element.
78// The attributes are described here:
79//
80// <https://svgwg.org/svg2-draft/text.html#TSpanAttributes>
81//
82// Attributes to parse:
83//   "x"
84//   "y"
85//   "dx"
86//   "dy"
87//   "rotate"
88//   "textLength"
89//   "lengthAdjust"
90impl ElementTrait for Text2 {
91    fn set_attributes(&mut self, attrs: &xml::Attributes, session: &Session) {
92        for (attr, value) in attrs.iter() {
93            match attr.expanded() {
94                expanded_name!("", "x") => set_attribute(&mut self.x, attr.parse(value), session),
95                expanded_name!("", "y") => set_attribute(&mut self.y, attr.parse(value), session),
96                expanded_name!("", "dx") => set_attribute(&mut self.dx, attr.parse(value), session),
97                expanded_name!("", "dy") => set_attribute(&mut self.dy, attr.parse(value), session),
98                expanded_name!("", "rotate") => {
99                    set_attribute(&mut self.rotate, attr.parse(value), session)
100                }
101                expanded_name!("", "textLength") => {
102                    set_attribute(&mut self.text_length, attr.parse(value), session)
103                }
104                expanded_name!("", "lengthAdjust") => {
105                    set_attribute(&mut self.length_adjust, attr.parse(value), session)
106                }
107                _ => (),
108            }
109        }
110    }
111}
112
113#[derive(Default)]
114#[allow(dead_code)]
115struct Character {
116    // https://www.w3.org/TR/SVG2/text.html#TextLayoutAlgorithm
117    // Section "11.5.1 Setup"
118    //
119    // global_index: u32,
120    // x: f64,
121    // y: f64,
122    // angle: Angle,
123    // hidden: bool,
124    addressable: bool,
125    character: char,
126    // must_include: bool,
127    // middle: bool,
128    // anchored_chunk: bool,
129}
130
131//              <tspan>   hello</tspan>
132// addressable:        tffttttt
133
134//              <tspan direction="ltr">A <tspan direction="rtl"> B </tspan> C</tspan>
135//              A xx B xx C          "xx" are bidi control characters
136// addressable: ttfffttffft
137
138// HOMEWORK
139#[allow(unused)]
140fn collapse_white_space(input: &str, white_space: WhiteSpace) -> Vec<Character> {
141    match white_space {
142        WhiteSpace::Normal | WhiteSpace::NoWrap => compute_normal_nowrap(input),
143        WhiteSpace::Pre | WhiteSpace::PreWrap => compute_pre_prewrap(input),
144        _ => unimplemented!(),
145    }
146}
147
148fn is_bidi_control(ch: char) -> bool {
149    use crate::text::directional_formatting_characters::*;
150    matches!(ch, LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)
151}
152
153// move to inline constant if conditions needs to change
154fn is_space(ch: char) -> bool {
155    matches!(ch, ' ' | '\t' | '\n')
156}
157
158// Summary of white-space rules from https://www.w3.org/TR/css-text-3/#white-space-property
159//
160//              New Lines   Spaces and Tabs   Text Wrapping   End-of-line   End-of-line
161//                                                            spaces        other space separators
162// -----------------------------------------------------------------------------------------------
163// normal       Collapse    Collapse          Wrap            Remove        Hang
164// pre          Preserve    Preserve          No wrap         Preserve      No wrap
165// nowrap       Collapse    Collapse          No wrap         Remove        Hang
166// pre-wrap     Preserve    Preserve          Wrap            Hang          Hang
167// break-spaces Preserve    Preserve          Wrap            Wrap          Wrap
168// pre-line     Preserve    Collapse          Wrap            Remove        Hang
169
170fn compute_normal_nowrap(input: &str) -> Vec<Character> {
171    let mut result: Vec<Character> = Vec::with_capacity(input.len());
172
173    let mut prev_was_space: bool = false;
174
175    for ch in input.chars() {
176        if is_bidi_control(ch) {
177            result.push(Character {
178                addressable: false,
179                character: ch,
180            });
181            continue;
182        }
183
184        if is_space(ch) {
185            if prev_was_space {
186                result.push(Character {
187                    addressable: false,
188                    character: ch,
189                });
190            } else {
191                result.push(Character {
192                    addressable: true,
193                    character: ch,
194                });
195                prev_was_space = true;
196            }
197        } else {
198            result.push(Character {
199                addressable: true,
200                character: ch,
201            });
202
203            prev_was_space = false;
204        }
205    }
206
207    result
208}
209
210fn compute_pre_prewrap(input: &str) -> Vec<Character> {
211    let mut result: Vec<Character> = Vec::with_capacity(input.len());
212
213    for ch in input.chars() {
214        if is_bidi_control(ch) {
215            result.push(Character {
216                addressable: false,
217                character: ch,
218            });
219        } else {
220            result.push(Character {
221                addressable: true,
222                character: ch,
223            });
224        }
225    }
226
227    result
228}
229
230fn get_bidi_control(element: &Element) -> BidiControl {
231    // Extract bidi control logic to separate function to avoid duplication
232    let computed_values = element.get_computed_values();
233
234    let unicode_bidi = computed_values.unicode_bidi();
235    let direction = computed_values.direction();
236
237    BidiControl::from_unicode_bidi_and_direction(unicode_bidi, direction)
238}
239
240// FIXME: Remove the following line when this code actually starts getting used outside of tests.
241#[allow(unused)]
242fn collect_text_from_node(node: &Node) -> String {
243    let mut result = String::new();
244
245    for edge in node.traverse() {
246        match edge {
247            NodeEdge::Start(child_node) => match *child_node.borrow() {
248                NodeData::Text(ref text) => {
249                    result.push_str(&text.get_string());
250                }
251
252                NodeData::Element(ref element) => match element.element_data {
253                    ElementData::TSpan(_) | ElementData::Text(_) | ElementData::Text2(_) => {
254                        let bidi_control = get_bidi_control(element);
255
256                        for &ch in bidi_control.start {
257                            result.push(ch);
258                        }
259                    }
260                    _ => {}
261                },
262            },
263
264            NodeEdge::End(child_node) => {
265                if let NodeData::Element(ref element) = *child_node.borrow() {
266                    match element.element_data {
267                        ElementData::TSpan(_) | ElementData::Text(_) | ElementData::Text2(_) => {
268                            let bidi_control = get_bidi_control(element);
269
270                            for &ch in bidi_control.end {
271                                result.push(ch);
272                            }
273                        }
274
275                        _ => {}
276                    }
277                }
278            }
279        }
280    }
281
282    result
283}
284
285/// A range onto which font properties are applied.
286///
287/// The indices are relative to a certain string, which is then passed on to Pango.
288/// The font properties will get translated to a pango::AttrList.
289#[allow(unused)]
290struct Attributes {
291    /// Start byte offset within the `text` of [`FormattedText`].
292    start_index: usize,
293
294    /// End byte offset within the `text` of [`FormattedText`].
295    end_index: usize,
296
297    /// Font style and properties for this range of text.
298    props: FontProperties,
299}
300
301/// Text and ranged attributes just prior to text layout.
302///
303/// This is what gets shipped to Pango for layout.
304#[allow(unused)]
305struct FormattedText {
306    text: String,
307    attributes: Vec<Attributes>,
308}
309
310// HOMEWORK:
311//
312// Traverse the text_node in the same way as when collecting the text.  See the comment below
313// on what needs to happen while traversing.  We are building a FormattedText that has only
314// the addressable characters AND the BidiControl chars, and the corresponding Attributtes/
315// for text styling.
316//
317//
318#[allow(unused)]
319fn build_formatted_text(
320    characters: &[Character],
321    text_node: &Node,
322    params: &NormalizeParams,
323) -> FormattedText {
324    let mut indices_stack = Vec::new();
325    let mut byte_index = 0;
326    let mut num_visited_characters = 0;
327    let mut text = String::new();
328    let mut attributes = Vec::new();
329
330    for edge in text_node.traverse() {
331        match edge {
332            NodeEdge::Start(child_node) => match *child_node.borrow() {
333                NodeData::Element(ref element) => match element.element_data {
334                    ElementData::TSpan(_) | ElementData::Text(_) | ElementData::Text2(_) => {
335                        indices_stack.push(byte_index);
336                        let bidi_control = get_bidi_control(element);
337                        for &ch in bidi_control.start {
338                            byte_index += ch.len_utf8();
339                            num_visited_characters += 1;
340                            text.push(ch);
341                        }
342                    }
343                    _ => {}
344                },
345                NodeData::Text(_) => {}
346            },
347
348            NodeEdge::End(child_node) => match *child_node.borrow() {
349                NodeData::Element(ref element) => match element.element_data {
350                    ElementData::TSpan(_) | ElementData::Text(_) | ElementData::Text2(_) => {
351                        let bidi_control = get_bidi_control(element);
352                        for &ch in bidi_control.end {
353                            byte_index += ch.len_utf8();
354                            num_visited_characters += 1;
355                            text.push(ch);
356                        }
357
358                        let start_index = indices_stack
359                            .pop()
360                            .expect("start_index must be pushed already");
361                        let values = element.get_computed_values();
362                        let font_props = FontProperties::new(values, params);
363
364                        if byte_index > start_index {
365                            attributes.push(Attributes {
366                                start_index,
367                                end_index: byte_index,
368                                props: font_props,
369                            });
370                        }
371                    }
372                    _ => {}
373                },
374
375                NodeData::Text(ref text_ref) => {
376                    let text_len = text_ref.get_string().chars().count();
377                    for character in characters
378                        .iter()
379                        .skip(num_visited_characters)
380                        .take(text_len)
381                    {
382                        if character.addressable {
383                            text.push(character.character);
384                            byte_index += character.character.len_utf8();
385                        }
386                        num_visited_characters += 1;
387                    }
388                }
389            },
390        }
391    }
392
393    FormattedText { text, attributes }
394}
395
396/// Builds a Pango attribute list from a FormattedText structure.
397///
398/// This function converts the text styling information in FormattedText
399/// into Pango attributes that can be applied to a Pango layout.
400#[allow(unused)]
401fn build_pango_attr_list(session: &Session, formatted_text: &FormattedText) -> pango::AttrList {
402    let attr_list = pango::AttrList::new();
403
404    if formatted_text.text.is_empty() {
405        return attr_list;
406    }
407
408    for attribute in &formatted_text.attributes {
409        // Skip invalid or empty ranges
410        if attribute.start_index >= attribute.end_index {
411            continue;
412        }
413
414        // Validate indices
415        let start_index = attribute.start_index.min(formatted_text.text.len());
416        let end_index = attribute.end_index.min(formatted_text.text.len());
417
418        assert!(start_index <= end_index);
419
420        let start_index =
421            u32::try_from(start_index).expect("Pango attribute index must fit in u32");
422        let end_index = u32::try_from(end_index).expect("Pango attribute index must fit in u32");
423
424        // Create font description
425        let mut font_desc = pango::FontDescription::new();
426        font_desc.set_family(&attribute.props.font_family.0);
427
428        // Handle font size scaling with bounds checking
429        if let Some(font_size) = PangoUnits::from_pixels(attribute.props.font_size) {
430            font_desc.set_size(font_size.0);
431        } else {
432            rsvg_log!(
433                session,
434                "font-size {} is out of bounds; skipping attribute range",
435                attribute.props.font_size
436            );
437        }
438
439        font_desc.set_weight(pango::Weight::from(attribute.props.font_weight));
440        font_desc.set_style(pango::Style::from(attribute.props.font_style));
441        font_desc.set_stretch(pango::Stretch::from(attribute.props.font_stretch));
442        font_desc.set_variant(pango::Variant::from(attribute.props.font_variant));
443
444        let mut font_attr = pango::AttrFontDesc::new(&font_desc).upcast();
445        font_attr.set_start_index(start_index);
446        font_attr.set_end_index(end_index);
447        attr_list.insert(font_attr);
448
449        // Add letter spacing with bounds checking
450        if attribute.props.letter_spacing != 0.0 {
451            if let Some(spacing) = PangoUnits::from_pixels(attribute.props.letter_spacing) {
452                let mut spacing_attr = pango::AttrInt::new_letter_spacing(spacing.0).upcast();
453                spacing_attr.set_start_index(start_index);
454                spacing_attr.set_end_index(end_index);
455                attr_list.insert(spacing_attr);
456            } else {
457                rsvg_log!(
458                    session,
459                    "letter-spacing {} is out of bounds; skipping attribute range",
460                    attribute.props.letter_spacing
461                );
462            }
463        }
464
465        // Add text decoration attributes
466        if attribute.props.text_decoration.overline {
467            let mut overline_attr = pango::AttrInt::new_overline(pango::Overline::Single).upcast();
468            overline_attr.set_start_index(start_index);
469            overline_attr.set_end_index(end_index);
470            attr_list.insert(overline_attr);
471        }
472
473        if attribute.props.text_decoration.underline {
474            let mut underline_attr =
475                pango::AttrInt::new_underline(pango::Underline::Single).upcast();
476            underline_attr.set_start_index(start_index);
477            underline_attr.set_end_index(end_index);
478            attr_list.insert(underline_attr);
479        }
480
481        if attribute.props.text_decoration.strike {
482            let mut strike_attr = pango::AttrInt::new_strikethrough(true).upcast();
483            strike_attr.set_start_index(start_index);
484            strike_attr.set_end_index(end_index);
485            attr_list.insert(strike_attr);
486        }
487    }
488
489    attr_list
490}
491
492struct PangoUnits(i32);
493
494impl PangoUnits {
495    fn from_pixels(v: f64) -> Option<Self> {
496        // We want (v * f64::from(pango::SCALE) + 0.5) as i32
497        // But check for overflow.
498        cast::i32(v * f64::from(pango::SCALE) + 0.5)
499            .ok()
500            .map(PangoUnits)
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use crate::document::Document;
507    use crate::dpi::Dpi;
508    use crate::element::ElementData;
509    use crate::node::NodeBorrow;
510    use crate::properties::{FontStyle, FontWeight};
511
512    use super::*;
513
514    #[test]
515    fn collects_text_in_a_single_string() {
516        let doc_str = br##"<?xml version="1.0" encoding="UTF-8"?>
517<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100">
518
519  <text2 id="sample">
520    Hello
521    <tspan font-style="italic">
522      <tspan font-weight="bold">bold</tspan>
523      world!
524    </tspan>
525    How are you.
526  </text2>
527</svg>
528"##;
529
530        let document = Document::load_from_bytes(doc_str);
531
532        let text2_node = document.lookup_internal_node("sample").unwrap();
533        assert!(matches!(
534            *text2_node.borrow_element_data(),
535            ElementData::Text2(_)
536        ));
537
538        let text_string = collect_text_from_node(&text2_node);
539        assert_eq!(
540            text_string,
541            "\n    \
542             Hello\n    \
543             \n      \
544             bold\n      \
545             world!\n    \
546             \n    \
547             How are you.\
548             \n  "
549        );
550    }
551
552    #[test]
553    fn adds_bidi_control_characters() {
554        let doc_str = br##"<?xml version="1.0" encoding="UTF-8"?>
555<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100">
556
557  <text2 id="sample">
558    Hello
559    <tspan direction="rtl" unicode-bidi="embed">
560      <tspan direction="ltr" unicode-bidi="isolate-override">bold</tspan>
561      world!
562    </tspan>
563    How are <tspan direction="rtl" unicode-bidi="isolate">you</tspan>.
564  </text2>
565</svg>
566"##;
567
568        let document = Document::load_from_bytes(doc_str);
569
570        let text2_node = document.lookup_internal_node("sample").unwrap();
571        assert!(matches!(
572            *text2_node.borrow_element_data(),
573            ElementData::Text2(_)
574        ));
575
576        let text_string = collect_text_from_node(&text2_node);
577        assert_eq!(
578            text_string,
579            "\n    \
580             Hello\n    \
581             \u{202b}\n      \
582             \u{2068}\u{202d}bold\u{202c}\u{2069}\n      \
583             world!\n    \
584             \u{202c}\n    \
585             How are \u{2067}you\u{2069}.\
586             \n  "
587        );
588    }
589
590    // Takes a string made of 't' and 'f' characters, and compares it
591    // to the `addressable` field of the Characters slice.
592    fn check_true_false_template(template: &str, characters: &[Character]) {
593        assert_eq!(characters.len(), template.len());
594
595        // HOMEWORK
596        // it's a loop with assert_eq!(characters[i].addressable, ...);
597        for (i, ch) in template.chars().enumerate() {
598            assert_eq!(characters[i].addressable, ch == 't');
599        }
600    }
601
602    fn check_modes_with_identical_processing(
603        string: &str,
604        template: &str,
605        mode1: WhiteSpace,
606        mode2: WhiteSpace,
607    ) {
608        let result1 = collapse_white_space(string, mode1);
609        check_true_false_template(template, &result1);
610
611        let result2 = collapse_white_space(string, mode2);
612        check_true_false_template(template, &result2);
613    }
614
615    // white-space="normal" and "nowrap"; these are processed in the same way
616
617    #[rustfmt::skip]
618    #[test]
619    fn handles_white_space_normal_trivial_case() {
620        check_modes_with_identical_processing(
621            "hello  world",
622            "ttttttfttttt",
623            WhiteSpace::Normal,
624            WhiteSpace::NoWrap
625        );
626    }
627
628    #[rustfmt::skip]
629    #[test]
630    fn handles_white_space_normal_start_of_the_line() {
631        check_modes_with_identical_processing(
632            "   hello  world",
633            "tffttttttfttttt",
634            WhiteSpace::Normal,
635            WhiteSpace::NoWrap
636        );
637    }
638
639    #[rustfmt::skip]
640    #[test]
641    fn handles_white_space_normal_ignores_bidi_control() {
642        check_modes_with_identical_processing(
643            "A \u{202b} B \u{202c} C",
644            "ttffttfft",
645            WhiteSpace::Normal,
646            WhiteSpace::NoWrap
647        );
648    }
649
650    // FIXME: here, we need to collapse newlines.  See section https://www.w3.org/TR/css-text-3/#line-break-transform
651    //
652    // Also, we need to test that consecutive newlines get replaced by a single space, FOR NOW,
653    // at least for languages where inter-word spaces actually exist.  For ideographic languages,
654    // consecutive newlines need to be removed.
655    /*
656    #[rustfmt::skip]
657    #[test]
658    fn handles_white_space_normal_collapses_newlines() {
659        check_modes_with_identical_processing(
660            "A \n  B \u{202c} C\n\n",
661            "ttfffttffttf",
662            WhiteSpace::Normal,
663            WhiteSpace::NoWrap
664        );
665    }
666    */
667
668    // white-space="pre" and "pre-wrap"; these are processed in the same way
669
670    #[rustfmt::skip]
671    #[test]
672    fn handles_white_space_pre_trivial_case() {
673        check_modes_with_identical_processing(
674            "   hello  \n  \n  \n\n\nworld",
675            "tttttttttttttttttttttttt",
676            WhiteSpace::Pre,
677            WhiteSpace::PreWrap
678        );
679    }
680
681    #[rustfmt::skip]
682    #[test]
683    fn handles_white_space_pre_ignores_bidi_control() {
684        check_modes_with_identical_processing(
685            "A  \u{202b} \n\n\n B \u{202c} C  ",
686            "tttftttttttftttt",
687            WhiteSpace::Pre,
688            WhiteSpace::PreWrap
689        );
690    }
691
692    // This is just to have a way to construct a `NormalizeParams` for tests; we don't
693    // actually care what it contains.
694    fn dummy_normalize_params() -> NormalizeParams {
695        NormalizeParams::from_dpi(Dpi::new(96.0, 96.0))
696    }
697
698    #[test]
699    fn builds_non_bidi_formatted_text() {
700        let doc_str = r##"<?xml version="1.0" encoding="UTF-8"?>
701<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100">
702
703  <text2 id="sample" font-family="Foobar">
704    Hello <tspan font-weight="bold">böld</tspan> world <tspan font-style="italic">in italics</tspan>!
705  </text2>
706</svg>
707"##;
708
709        let document = Document::load_from_bytes(doc_str.as_bytes());
710
711        let text2_node = document.lookup_internal_node("sample").unwrap();
712        assert!(matches!(
713            *text2_node.borrow_element_data(),
714            ElementData::Text2(_)
715        ));
716
717        let collected_text = collect_text_from_node(&text2_node);
718        let collapsed_characters = collapse_white_space(&collected_text, WhiteSpace::Normal);
719
720        let formatted = build_formatted_text(
721            &collapsed_characters,
722            &text2_node,
723            &dummy_normalize_params(),
724        );
725
726        assert_eq!(&formatted.text, "\nHello böld world in italics!\n");
727
728        // "böld" (note that the ö takes two bytes in UTF-8)
729        assert_eq!(formatted.attributes[0].start_index, 7);
730        assert_eq!(formatted.attributes[0].end_index, 12);
731        assert_eq!(formatted.attributes[0].props.font_weight, FontWeight::Bold);
732
733        // "in italics"
734        assert_eq!(formatted.attributes[1].start_index, 19);
735        assert_eq!(formatted.attributes[1].end_index, 29);
736        assert_eq!(formatted.attributes[1].props.font_style, FontStyle::Italic);
737
738        // the whole string
739        assert_eq!(formatted.attributes[2].start_index, 0);
740        assert_eq!(formatted.attributes[2].end_index, 31);
741        assert_eq!(formatted.attributes[2].props.font_family.0, "Foobar");
742    }
743
744    #[test]
745    fn builds_bidi_formatted_text() {
746        let doc_str = r##"<?xml version="1.0" encoding="UTF-8"?>
747<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100">
748
749  <text2 id="sample" font-family="Foobar">
750    LTR<tspan direction="rtl" unicode-bidi="embed" font-style="italic">RTL</tspan><tspan font-weight="bold">LTR</tspan>
751  </text2>
752</svg>
753"##;
754
755        let document = Document::load_from_bytes(doc_str.as_bytes());
756
757        let text2_node = document.lookup_internal_node("sample").unwrap();
758        assert!(matches!(
759            *text2_node.borrow_element_data(),
760            ElementData::Text2(_)
761        ));
762
763        let collected_text = collect_text_from_node(&text2_node);
764        let collapsed_characters = collapse_white_space(&collected_text, WhiteSpace::Normal);
765
766        let formatted = build_formatted_text(
767            &collapsed_characters,
768            &text2_node,
769            &dummy_normalize_params(),
770        );
771
772        assert_eq!(&formatted.text, "\nLTR\u{202b}RTL\u{202c}LTR\n");
773
774        // "RTL" surrounded by bidi control chars
775        assert_eq!(formatted.attributes[0].start_index, 4);
776        assert_eq!(formatted.attributes[0].end_index, 13);
777        assert_eq!(formatted.attributes[0].props.font_style, FontStyle::Italic);
778
779        // "LTR" at the end
780        assert_eq!(formatted.attributes[1].start_index, 13);
781        assert_eq!(formatted.attributes[1].end_index, 16);
782        assert_eq!(formatted.attributes[1].props.font_weight, FontWeight::Bold);
783
784        // the whole string
785        assert_eq!(formatted.attributes[2].start_index, 0);
786        assert_eq!(formatted.attributes[2].end_index, 17);
787        assert_eq!(formatted.attributes[2].props.font_family.0, "Foobar");
788    }
789}