Skip to main content

layout/flow/inline/
construct.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::borrow::Cow;
6use std::cell::LazyCell;
7use std::ops::Range;
8use std::sync::Arc;
9
10use atomic_refcell::AtomicRefCell;
11use fonts::TextByteRange;
12use icu_properties::BidiClass;
13use layout_api::{LayoutNode, ScriptSelection};
14use servo_base::text::{RangeAny, Utf32CodeUnits};
15use style::computed_values::direction::T as Direction;
16use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
17use style::dom::NodeInfo;
18use style::selector_parser::PseudoElement;
19use unicode_bidi::Level;
20use unicode_categories::UnicodeCategories;
21
22use super::text_run::TextRun;
23use super::{
24    InlineBox, InlineBoxIdentifier, InlineBoxes, InlineFormattingContext, InlineItem,
25    SharedInlineStyles,
26};
27use crate::cell::ArcRefCell;
28use crate::context::LayoutContext;
29use crate::dom::{LayoutBox, NodeExt};
30use crate::dom_traversal::{BoxTreeString, NodeAndStyleInfo};
31use crate::flow::BlockLevelBox;
32use crate::flow::float::FloatBox;
33use crate::flow::inline::text_run::SharedTextRunData;
34use crate::flow::inline::text_transform::{OffsetMap, TextTransformationIterator};
35use crate::formatting_contexts::IndependentFormattingContext;
36use crate::positioned::AbsolutelyPositionedBox;
37use crate::style_ext::ComputedValuesExt;
38
39#[derive(Default)]
40pub(crate) struct InlineFormattingContextBuilder {
41    /// A stack of [`SharedInlineStyles`] including one for the root, one for each inline box on the
42    /// inline box stack, and importantly, one for every `display: contents` element that we are
43    /// currently processing. Normally `display: contents` elements don't affect the structure of
44    /// the [`InlineFormattingContext`], but the styles they provide do style their children.
45    pub shared_inline_styles_stack: Vec<SharedInlineStyles>,
46
47    /// The collection of text strings that make up this [`InlineFormattingContext`] under
48    /// construction.
49    pub text_segments: Vec<String>,
50
51    /// The current offset in the final text string of this [`InlineFormattingContext`],
52    /// used to properly set the text range of new [`InlineItem::TextRun`]s.
53    current_text_offset: usize,
54
55    /// The current character offset in the final text string of this [`InlineFormattingContext`],
56    /// used to properly set the text range of new [`InlineItem::TextRun`]s. Note that this is
57    /// different from the UTF-8 code point offset.
58    current_character_offset: usize,
59
60    /// Whether the last processed node ended with whitespace. This is used to
61    /// implement rule 4 of <https://www.w3.org/TR/css-text-3/#collapse>:
62    ///
63    /// > Any collapsible space immediately following another collapsible space—even one
64    /// > outside the boundary of the inline containing that space, provided both spaces are
65    /// > within the same inline formatting context—is collapsed to have zero advance width.
66    /// > (It is invisible, but retains its soft wrap opportunity, if any.)
67    last_inline_box_ended_with_collapsible_white_space: bool,
68
69    /// Whether or not the current state of the inline formatting context is on a word boundary
70    /// for the purposes of `text-transform: capitalize`.
71    on_word_boundary: bool,
72
73    /// Whether or not this inline formatting context will contain floats.
74    pub contains_floats: bool,
75
76    /// The current list of [`InlineItem`]s in this [`InlineFormattingContext`] under
77    /// construction. This is stored in a flat list to make it easy to access the last
78    /// item.
79    pub inline_items: Vec<InlineItem>,
80
81    /// The current [`InlineBox`] tree of this [`InlineFormattingContext`] under construction.
82    pub inline_boxes: InlineBoxes,
83
84    /// The ongoing stack of inline boxes stack of the builder.
85    ///
86    /// Contains all the currently ongoing inline boxes we entered so far.
87    /// The traversal is at all times as deep in the tree as this stack is,
88    /// which is why the code doesn't need to keep track of the actual
89    /// container root (see `handle_inline_level_element`).
90    ///
91    /// When an inline box ends, it's removed from this stack.
92    inline_box_stack: Vec<InlineBoxIdentifier>,
93
94    /// Whether this [`InlineFormattingContextBuilder`] is empty for the purposes of ignoring
95    /// during box tree construction. An IFC is empty if it only contains TextRuns with
96    /// completely collapsible whitespace. When that happens it can be ignored completely.
97    pub is_empty: bool,
98
99    /// Whether or not the `::first-letter` pseudo-element of this inline formatting context
100    /// has been processed yet.
101    has_processed_first_letter: bool,
102
103    /// Whether or not the inline formatting context under construction has any kind of
104    /// right-to-left content such as a character with an RTL character class or a `dir`
105    /// attribute specifying right-to-left content.
106    pub has_right_to_left_content: bool,
107
108    /// An [`OffsetMap`] used to map selections from their offset before inline formatting
109    /// context text transformation to their offsets after transformation.
110    pub offset_map: ArcRefCell<OffsetMap>,
111}
112
113impl InlineFormattingContextBuilder {
114    /// <https://drafts.csswg.org/css-text/#white-space>:
115    /// > Except where specified otherwise, white space processing in CSS affects only the document
116    /// > white space characters: spaces (U+0020), tabs (U+0009), and segment breaks.
117    ///
118    /// From <https://github.com/w3c/csswg-drafts/issues/5147#issuecomment-637816669>:
119    /// > HTML clearly treats CR, LF, and CRLF as segment breaks.
120    ///
121    /// Other browsers also consider the form feed character (0x0c) to be document white space, it
122    /// seems.
123    ///
124    /// Taken all together, this is equivalent to the WhatWG Infra Standard's definition of ASCII
125    /// white space.
126    pub(crate) fn is_document_white_space(character: char) -> bool {
127        character.is_ascii_whitespace()
128    }
129
130    pub(crate) fn new(info: &NodeAndStyleInfo, context: &LayoutContext) -> Self {
131        let has_right_to_left_content = info.style.get_inherited_box().direction == Direction::Rtl;
132        Self {
133            // For the purposes of `text-transform: capitalize` the start of the IFC is a word boundary.
134            on_word_boundary: true,
135            is_empty: true,
136            shared_inline_styles_stack: vec![SharedInlineStyles::from_info_and_context(
137                info, context,
138            )],
139            has_right_to_left_content,
140            ..Default::default()
141        }
142    }
143
144    pub(crate) fn currently_processing_inline_box(&self) -> bool {
145        !self.inline_box_stack.is_empty()
146    }
147
148    fn push_control_character_string(&mut self, string_to_push: &str) {
149        self.text_segments.push(string_to_push.to_owned());
150        self.current_text_offset += string_to_push.len();
151
152        let new_characters = Utf32CodeUnits::length_of(string_to_push);
153        self.current_character_offset += new_characters.0;
154        self.offset_map
155            .borrow_mut()
156            .push_range(new_characters, new_characters);
157    }
158
159    fn shared_inline_styles(&self) -> SharedInlineStyles {
160        self.shared_inline_styles_stack
161            .last()
162            .expect("Should always have at least one SharedInlineStyles")
163            .clone()
164    }
165
166    pub(crate) fn push_atomic(
167        &mut self,
168        independent_formatting_context_creator: impl FnOnce()
169            -> ArcRefCell<IndependentFormattingContext>,
170        old_layout_box: Option<LayoutBox>,
171    ) -> InlineItem {
172        // If there is an existing undamaged layout box that's compatible, use that.
173        let independent_formatting_context = old_layout_box
174            .and_then(|layout_box| match layout_box {
175                LayoutBox::InlineLevel(InlineItem::Atomic(atomic, ..)) => Some(atomic),
176                _ => None,
177            })
178            .unwrap_or_else(independent_formatting_context_creator);
179
180        let inline_level_box = InlineItem::Atomic(
181            independent_formatting_context,
182            self.current_text_offset,
183            Level::ltr(), /* This will be assigned later if necessary. */
184        );
185        self.inline_items.push(inline_level_box.clone());
186        self.is_empty = false;
187
188        // Push an object replacement character for this atomic, which will ensure that the line breaker
189        // inserts a line breaking opportunity here.
190        self.push_control_character_string("\u{fffc}");
191
192        self.last_inline_box_ended_with_collapsible_white_space = false;
193        self.on_word_boundary = true;
194
195        // Atomics such as images should prevent any following text as being interpreted as the first letter.
196        self.has_processed_first_letter = true;
197
198        inline_level_box
199    }
200
201    pub(crate) fn push_absolutely_positioned_box(
202        &mut self,
203        absolutely_positioned_box_creator: impl FnOnce() -> ArcRefCell<AbsolutelyPositionedBox>,
204        old_layout_box: Option<LayoutBox>,
205    ) -> InlineItem {
206        let absolutely_positioned_box = old_layout_box
207            .and_then(|layout_box| match layout_box {
208                LayoutBox::InlineLevel(InlineItem::OutOfFlowAbsolutelyPositionedBox(
209                    positioned_box,
210                    ..,
211                )) => Some(positioned_box),
212                _ => None,
213            })
214            .unwrap_or_else(absolutely_positioned_box_creator);
215
216        // We cannot just reuse the old inline item, because the `current_text_offset` may have changed.
217        let inline_level_box = InlineItem::OutOfFlowAbsolutelyPositionedBox(
218            absolutely_positioned_box,
219            self.current_text_offset,
220        );
221
222        self.inline_items.push(inline_level_box.clone());
223        self.is_empty = false;
224        inline_level_box
225    }
226
227    pub(crate) fn push_float_box(
228        &mut self,
229        float_box_creator: impl FnOnce() -> ArcRefCell<FloatBox>,
230        old_layout_box: Option<LayoutBox>,
231    ) -> InlineItem {
232        let inline_level_box = old_layout_box
233            .and_then(|layout_box| match layout_box {
234                LayoutBox::InlineLevel(inline_item) => Some(inline_item),
235                _ => None,
236            })
237            .unwrap_or_else(|| InlineItem::OutOfFlowFloatBox(float_box_creator()));
238
239        debug_assert!(
240            matches!(inline_level_box, InlineItem::OutOfFlowFloatBox(..),),
241            "Created float box with incompatible `old_layout_box`"
242        );
243
244        self.inline_items.push(inline_level_box.clone());
245        self.is_empty = false;
246        self.contains_floats = true;
247        inline_level_box
248    }
249
250    pub(crate) fn push_block_level_box(&mut self, block_level: ArcRefCell<BlockLevelBox>) {
251        assert!(self.currently_processing_inline_box());
252        self.contains_floats = self.contains_floats || block_level.borrow().contains_floats();
253        self.inline_items.push(InlineItem::BlockLevel(block_level));
254    }
255
256    pub(crate) fn start_inline_box(
257        &mut self,
258        inline_box_creator: impl FnOnce() -> ArcRefCell<InlineBox>,
259        old_layout_box: Option<LayoutBox>,
260    ) -> InlineItem {
261        // If there is an existing undamaged layout box that's compatible, use the `InlineBox` within it.
262        let inline_box = old_layout_box
263            .and_then(|layout_box| match layout_box {
264                LayoutBox::InlineLevel(InlineItem::StartInlineBox(inline_box)) => Some(inline_box),
265                _ => None,
266            })
267            .unwrap_or_else(inline_box_creator);
268
269        let borrowed_inline_box = inline_box.borrow();
270
271        let style = &borrowed_inline_box.base.style;
272        self.push_control_character_string(style.bidi_control_chars().0);
273        self.has_right_to_left_content =
274            self.has_right_to_left_content || style.get_inherited_box().direction == Direction::Rtl;
275
276        self.shared_inline_styles_stack
277            .push(borrowed_inline_box.shared_inline_styles.clone());
278        std::mem::drop(borrowed_inline_box);
279
280        let identifier = self.inline_boxes.start_inline_box(inline_box.clone());
281        let inline_item = InlineItem::StartInlineBox(inline_box);
282        self.inline_items.push(inline_item.clone());
283        self.inline_box_stack.push(identifier);
284        self.is_empty = false;
285        inline_item
286    }
287
288    /// End the ongoing inline box in this [`InlineFormattingContextBuilder`], returning
289    /// shared references to all of the box tree items that were created for it. More than
290    /// a single box tree items may be produced for a single inline box when that inline
291    /// box is split around a block-level element.
292    pub(crate) fn end_inline_box(&mut self) {
293        let identifier = self
294            .inline_box_stack
295            .pop()
296            .expect("Ended non-existent inline box");
297        let inline_level_box = self.inline_boxes.get(&identifier);
298
299        self.shared_inline_styles_stack.pop();
300        self.inline_items
301            .push(InlineItem::EndInlineBox(inline_level_box.clone()));
302        self.inline_boxes.end_inline_box(identifier);
303        let bidi_control_chars = inline_level_box.borrow().base.style.bidi_control_chars();
304        self.push_control_character_string(bidi_control_chars.1);
305    }
306
307    /// This is like [`Self::push_text`], except that it might possibly add an anonymous box if
308    ///
309    ///  - This inline formatting context has a `::first-letter` style.
310    ///  - No anonymous box for `::first-letter` has been added yet.
311    ///  - First letter content is detected in this text.
312    ///
313    /// Note that this should only be used when processing text in block containers.
314    pub(crate) fn push_text_with_possible_first_letter<'dom>(
315        &mut self,
316        text: BoxTreeString<'dom>,
317        info: &NodeAndStyleInfo<'dom>,
318        container_info: &NodeAndStyleInfo<'dom>,
319        layout_context: &LayoutContext,
320    ) -> bool {
321        let document_selection = info.node.document_selection_in_text_node();
322        if self.has_processed_first_letter || !container_info.pseudo_element_chain().is_empty() {
323            self.push_text(text, info, document_selection);
324            return false;
325        }
326
327        let Some(first_letter_info) =
328            container_info.with_pseudo_element(layout_context, PseudoElement::FirstLetter)
329        else {
330            self.push_text(text, info, document_selection);
331            return false;
332        };
333
334        let first_letter_range = first_letter_range(&text[..]);
335        if first_letter_range.is_empty() {
336            return false;
337        }
338
339        // Push any leading white space first.
340        let first_letter_range_u32 = LazyCell::new(|| {
341            Utf32CodeUnits::length_of(&text[..first_letter_range.start])..
342                Utf32CodeUnits::length_of(&text[..first_letter_range.end])
343        });
344        if first_letter_range.start != 0 {
345            let leading_whitespace_range = 0..first_letter_range.start;
346            let leading_whitespace_selection_range =
347                document_selection.and_then(|document_selection| {
348                    let leading_whitespace_range_u32 = RangeAny {
349                        start: None,
350                        end: Some(first_letter_range_u32.start),
351                    };
352                    document_selection.intersect(leading_whitespace_range_u32)
353                });
354
355            self.push_text(
356                Cow::Borrowed(&text[leading_whitespace_range]).into(),
357                info,
358                leading_whitespace_selection_range,
359            );
360        }
361
362        // Push the first-letter text into an anonymous box with the `::first-letter` style.
363        let box_slot = first_letter_info.node.box_slot();
364        let inline_item = self.start_inline_box(
365            || ArcRefCell::new(InlineBox::new(&first_letter_info, layout_context)),
366            None,
367        );
368        box_slot.set(LayoutBox::InlineLevel(inline_item));
369
370        let first_letter_text = Cow::Borrowed(&text[first_letter_range.clone()]);
371        let first_letter_selection_range = document_selection.and_then(|document_selection| {
372            document_selection
373                .intersect((*first_letter_range_u32).clone().into())
374                .map(|range| range.map(|offset| offset - first_letter_range_u32.start))
375        });
376        self.push_text(
377            first_letter_text.into(),
378            &first_letter_info,
379            first_letter_selection_range,
380        );
381        self.end_inline_box();
382        self.has_processed_first_letter = true;
383
384        // Now push the non-first-letter text.
385        let remaining_selection_range = document_selection.and_then(|document_selection| {
386            let remaining_text_range_u32 = RangeAny {
387                start: Some(first_letter_range_u32.end),
388                end: document_selection.end,
389            };
390            document_selection
391                .intersect(remaining_text_range_u32)
392                .map(|range| range.map(|offset| offset - first_letter_range_u32.end))
393        });
394        self.push_text(
395            Cow::Borrowed(&text[first_letter_range.end..]).into(),
396            info,
397            remaining_selection_range,
398        );
399
400        true
401    }
402
403    pub(crate) fn push_text<'dom>(
404        &mut self,
405        text: BoxTreeString<'dom>,
406        info: &NodeAndStyleInfo<'dom>,
407        document_selection: Option<RangeAny<Utf32CodeUnits>>,
408    ) {
409        let mut offset_map = self.offset_map.borrow_mut();
410        let original_size_before = offset_map.total_original_size();
411
412        let bidi_class_map = icu_properties::maps::bidi_class();
413        let white_space_collapse = info.style.clone_white_space_collapse();
414        let mut character_count = 0;
415        let mut new_text = String::with_capacity(text.len());
416        for iteration in TextTransformationIterator::new(
417            &text,
418            &info.style,
419            self.last_inline_box_ended_with_collapsible_white_space,
420            self.on_word_boundary,
421        ) {
422            offset_map.push_iteration(&iteration);
423            for &character in iteration.characters() {
424                character_count += 1;
425
426                // If this character has a strong right-to-left class the new inline formatting context will
427                // need to be BiDi-aware. This match is derived from the list of strong right-to-left classes
428                // at https://www.unicode.org/reports/tr44/#Bidi_Class_Values.
429                self.has_right_to_left_content = self.has_right_to_left_content ||
430                    matches!(
431                        bidi_class_map.get(character),
432                        BidiClass::RightToLeft |
433                            BidiClass::ArabicLetter |
434                            BidiClass::RightToLeftEmbedding |
435                            BidiClass::RightToLeftIsolate |
436                            BidiClass::RightToLeftOverride
437                    );
438
439                self.is_empty = self.is_empty &&
440                    match white_space_collapse {
441                        WhiteSpaceCollapse::Collapse => Self::is_document_white_space(character),
442                        WhiteSpaceCollapse::PreserveBreaks => {
443                            Self::is_document_white_space(character) && character != '\n'
444                        },
445                        WhiteSpaceCollapse::Preserve | WhiteSpaceCollapse::BreakSpaces => false,
446                    };
447
448                new_text.push(character)
449            }
450        }
451
452        if new_text.is_empty() {
453            return;
454        }
455
456        let selection = info.node.form_control_selection_in_text_node().or_else(|| {
457            let document_selection = document_selection?;
458            // Range unbounded at the start: the concrete start is offset zero.
459            let start = document_selection.start.unwrap_or(Utf32CodeUnits(0));
460            // Range unbounded at the end: the concrete end is the full length.
461            let end = document_selection
462                .end
463                .unwrap_or(offset_map.total_original_size() - original_size_before);
464
465            if start == end {
466                return None;
467            }
468            debug_assert!(end > start);
469
470            Some(Arc::new(AtomicRefCell::new(ScriptSelection {
471                range: TextByteRange::default(),
472                character_range: start.0..end.0,
473                enabled: true,
474            })))
475        });
476
477        if let Some(last_character) = new_text.chars().next_back() {
478            self.on_word_boundary = last_character.is_whitespace();
479            self.last_inline_box_ended_with_collapsible_white_space =
480                self.on_word_boundary && white_space_collapse != WhiteSpaceCollapse::Preserve;
481        }
482
483        let new_utf8_range = self.current_text_offset..self.current_text_offset + new_text.len();
484        self.current_text_offset = new_utf8_range.end;
485
486        let new_character_range =
487            self.current_character_offset..self.current_character_offset + character_count;
488        self.current_character_offset = new_character_range.end;
489
490        self.text_segments.push(new_text);
491
492        let current_inline_styles = self.shared_inline_styles();
493        let box_slot = info.node.is_text_node().then(|| info.node.box_slot());
494        let text_run = ArcRefCell::new(TextRun::new(
495            info.into(),
496            SharedTextRunData {
497                inline_styles: current_inline_styles,
498                character_range_in_ifc_text: new_character_range,
499                original_offset: original_size_before,
500                selection,
501                offset_map: self.offset_map.clone(),
502            }
503            .into(),
504            new_utf8_range,
505            box_slot
506                .as_ref()
507                .and_then(|box_slot| box_slot.take_layout_box_as_text_run()),
508        ));
509        self.inline_items
510            .push(InlineItem::TextRun(text_run.clone()));
511
512        if let Some(box_slot) = box_slot {
513            box_slot.set(LayoutBox::Text(text_run));
514        }
515    }
516
517    pub(crate) fn enter_display_contents(&mut self, shared_inline_styles: SharedInlineStyles) {
518        self.shared_inline_styles_stack.push(shared_inline_styles);
519    }
520
521    pub(crate) fn leave_display_contents(&mut self) {
522        self.shared_inline_styles_stack.pop();
523    }
524
525    /// Finish the current inline formatting context, returning [`None`] if the context was empty.
526    pub(crate) fn finish(
527        self,
528        layout_context: &LayoutContext,
529        has_first_formatted_line: bool,
530        is_single_line_text_input: bool,
531        default_bidi_level: Level,
532    ) -> Option<InlineFormattingContext> {
533        if self.is_empty {
534            return None;
535        }
536
537        assert!(self.inline_box_stack.is_empty());
538        debug_assert_eq!(
539            self.offset_map.borrow().total_final_size().0,
540            self.current_character_offset
541        );
542
543        Some(InlineFormattingContext::new_with_builder(
544            self,
545            layout_context,
546            has_first_formatted_line,
547            is_single_line_text_input,
548            default_bidi_level,
549        ))
550    }
551}
552
553/// Computes the range of the first letter.
554///
555/// The range includes any preceding punctuation and white space, and any trailing punctuation. Any
556/// non-punctuation following the letter/number/symbol of first-letter ends the range. Intervening
557/// spaces within trailing punctuation are not supported yet.
558///
559/// If the resulting range is empty, no compatible first-letter text was found.
560///
561/// <https://drafts.csswg.org/css-pseudo/#first-letter-pattern>
562fn first_letter_range(text: &str) -> Range<usize> {
563    enum State {
564        /// All characters that precede the `PrecedingWhitespaceAndPunctuation` state.
565        Start,
566        /// All preceding punctuation and intervening whitepace that precedes the `Lns` state.
567        PrecedingPunctuation,
568        /// Unicode general category L: letter, N: number and S: symbol
569        Lns,
570        /// All punctuation (but no whitespace or other characters), that
571        /// come after the `Lns` state.
572        TrailingPunctuation,
573    }
574
575    let mut start = 0;
576    let mut state = State::Start;
577    for (index, character) in text.char_indices() {
578        match &mut state {
579            State::Start => {
580                if character.is_letter() || character.is_number() || character.is_symbol() {
581                    start = index;
582                    state = State::Lns;
583                } else if character.is_punctuation() {
584                    start = index;
585                    state = State::PrecedingPunctuation
586                }
587            },
588            State::PrecedingPunctuation => {
589                if character.is_letter() || character.is_number() || character.is_symbol() {
590                    state = State::Lns;
591                } else if !character.is_separator_space() && !character.is_punctuation() {
592                    return 0..0;
593                }
594            },
595            State::Lns => {
596                // TODO: Implement support for intervening spaces
597                // <https://drafts.csswg.org/css-pseudo/#first-letter-pattern>
598                if character.is_punctuation() &&
599                    !character.is_punctuation_open() &&
600                    !character.is_punctuation_dash()
601                {
602                    state = State::TrailingPunctuation;
603                } else {
604                    return start..index;
605                }
606            },
607            State::TrailingPunctuation => {
608                // TODO: Implement support for intervening spaces
609                // <https://drafts.csswg.org/css-pseudo/#first-letter-pattern>
610                if character.is_punctuation() &&
611                    !character.is_punctuation_open() &&
612                    !character.is_punctuation_dash()
613                {
614                    continue;
615                } else {
616                    return start..index;
617                }
618            },
619        }
620    }
621
622    match state {
623        State::Start | State::PrecedingPunctuation => 0..0,
624        State::Lns | State::TrailingPunctuation => start..text.len(),
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    fn assert_first_letter_eq(text: &str, expected: &str) {
633        let range = first_letter_range(text);
634        assert_eq!(&text[range], expected);
635    }
636
637    #[test]
638    fn test_first_letter_range() {
639        // All spaces
640        assert_first_letter_eq("", "");
641        assert_first_letter_eq("  ", "");
642
643        // Spaces and punctuation only
644        assert_first_letter_eq("(", "");
645        assert_first_letter_eq(" (", "");
646        assert_first_letter_eq("( ", "");
647        assert_first_letter_eq("()", "");
648
649        // Invalid chars
650        assert_first_letter_eq("\u{0903}", "");
651
652        // First letter only
653        assert_first_letter_eq("A", "A");
654        assert_first_letter_eq(" A", "A");
655        assert_first_letter_eq("A ", "A");
656        assert_first_letter_eq(" A ", "A");
657
658        // Word
659        assert_first_letter_eq("App", "A");
660        assert_first_letter_eq(" App", "A");
661        assert_first_letter_eq("App ", "A");
662
663        // Preceding punctuation(s), intervening spaces and first letter
664        assert_first_letter_eq(r#""A"#, r#""A"#);
665        assert_first_letter_eq(r#" "A"#, r#""A"#);
666        assert_first_letter_eq(r#""A "#, r#""A"#);
667        assert_first_letter_eq(r#"" A"#, r#"" A"#);
668        assert_first_letter_eq(r#" "A "#, r#""A"#);
669        assert_first_letter_eq(r#"("A"#, r#"("A"#);
670        assert_first_letter_eq(r#" ("A"#, r#"("A"#);
671        assert_first_letter_eq(r#"( "A"#, r#"( "A"#);
672        assert_first_letter_eq(r#"[ ( "A"#, r#"[ ( "A"#);
673
674        // First letter and succeeding punctuation(s)
675        // TODO: modify test cases when intervening spaces in succeeding puntuations is supported
676        assert_first_letter_eq(r#"A""#, r#"A""#);
677        assert_first_letter_eq(r#"A" "#, r#"A""#);
678        assert_first_letter_eq(r#"A)]"#, r#"A)]"#);
679        assert_first_letter_eq(r#"A" )]"#, r#"A""#);
680        assert_first_letter_eq(r#"A)] >"#, r#"A)]"#);
681
682        // All
683        assert_first_letter_eq(r#" ("A" )]"#, r#"("A""#);
684        assert_first_letter_eq(r#" ("A")] >"#, r#"("A")]"#);
685
686        // Non ASCII chars
687        assert_first_letter_eq("一", "一");
688        assert_first_letter_eq(" 一 ", "一");
689        assert_first_letter_eq("一二三", "一");
690        assert_first_letter_eq(" 一二三 ", "一");
691        assert_first_letter_eq("(一二三)", "(一");
692        assert_first_letter_eq(" (一二三) ", "(一");
693        assert_first_letter_eq("((一", "((一");
694        assert_first_letter_eq(" ( (一", "( (一");
695        assert_first_letter_eq("一)", "一)");
696        assert_first_letter_eq("一))", "一))");
697        assert_first_letter_eq("一) )", "一)");
698    }
699}