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