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::{ControlFlow, Range};
8
9use icu_properties::BidiClass;
10use layout_api::{LayoutNode, SharedSelection};
11use servo_base::text::{RangeAny, Utf32CodeUnits};
12use style::computed_values::direction::T as Direction;
13use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
14use style::dom::NodeInfo;
15use style::selector_parser::PseudoElement;
16use unicode_bidi::Level;
17use unicode_categories::UnicodeCategories;
18
19use super::text_run::TextRun;
20use super::{
21    InlineBox, InlineBoxIdentifier, InlineBoxes, InlineFormattingContext, InlineItem,
22    SharedInlineStyles,
23};
24use crate::cell::ArcRefCell;
25use crate::context::LayoutContext;
26use crate::dom::{LayoutBox, NodeExt};
27use crate::dom_traversal::{BoxTreeString, NodeAndStyleInfo};
28use crate::flow::BlockLevelBox;
29use crate::flow::float::FloatBox;
30use crate::flow::inline::text_transform::{OffsetMap, TextTransformationIterator};
31use crate::formatting_contexts::IndependentFormattingContext;
32use crate::positioned::AbsolutelyPositionedBox;
33use crate::style_ext::ComputedValuesExt;
34
35#[derive(Default)]
36pub(crate) struct InlineFormattingContextBuilder {
37    /// A stack of [`SharedInlineStyles`] including one for the root, one for each inline box on the
38    /// inline box stack, and importantly, one for every `display: contents` element that we are
39    /// currently processing. Normally `display: contents` elements don't affect the structure of
40    /// the [`InlineFormattingContext`], but the styles they provide do style their children.
41    pub shared_inline_styles_stack: Vec<SharedInlineStyles>,
42
43    /// The collection of text strings that make up this [`InlineFormattingContext`] under
44    /// construction.
45    pub text_segments: Vec<String>,
46
47    /// The current offset in the final text string of this [`InlineFormattingContext`],
48    /// used to properly set the text range of new [`InlineItem::TextRun`]s.
49    current_text_offset: usize,
50
51    /// The current character offset in the final text string of this [`InlineFormattingContext`],
52    /// used to properly set the text range of new [`InlineItem::TextRun`]s. Note that this is
53    /// different from the UTF-8 code point offset.
54    current_character_offset: usize,
55
56    /// If the [`InlineFormattingContext`] that we are building has a selection shared with its
57    /// originating node in the DOM, this will not be `None`.
58    pub shared_selection: Option<SharedSelection>,
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: 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            shared_selection: info.node.selection(),
140            has_right_to_left_content,
141            ..Default::default()
142        }
143    }
144
145    pub(crate) fn currently_processing_inline_box(&self) -> bool {
146        !self.inline_box_stack.is_empty()
147    }
148
149    fn push_control_character_string(&mut self, string_to_push: &str) {
150        self.text_segments.push(string_to_push.to_owned());
151        self.current_text_offset += string_to_push.len();
152
153        let new_characters = Utf32CodeUnits::length_of(string_to_push);
154        self.current_character_offset += new_characters.0;
155        self.offset_map
156            .push_range(Utf32CodeUnits(0), 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 bidi_class_map = icu_properties::maps::bidi_class();
410        let white_space_collapse = info.style.clone_white_space_collapse();
411        let original_size_before = self.offset_map.total_original_size();
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            self.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        let document_selection = document_selection.map(|document_selection| {
455            let start = document_selection
456                .start
457                .map(|offset| self.offset_map.map(offset))
458                // Range unbounded at the start: the concrete start is offset zero
459                .unwrap_or(Utf32CodeUnits(0));
460            let end = document_selection
461                .end
462                .map(|offset| self.offset_map.map(offset))
463                // Range unbounded at the end: the concrete end is the full length
464                .unwrap_or(Utf32CodeUnits(character_count));
465            original_size_before + start..original_size_before + end
466        });
467
468        if let Some(last_character) = new_text.chars().next_back() {
469            self.on_word_boundary = last_character.is_whitespace();
470            self.last_inline_box_ended_with_collapsible_white_space =
471                self.on_word_boundary && white_space_collapse != WhiteSpaceCollapse::Preserve;
472        }
473
474        let new_utf8_range = self.current_text_offset..self.current_text_offset + new_text.len();
475        self.current_text_offset = new_utf8_range.end;
476
477        let new_character_range =
478            self.current_character_offset..self.current_character_offset + character_count;
479        self.current_character_offset = new_character_range.end;
480
481        self.text_segments.push(new_text);
482
483        if self
484            .try_to_push_text_range_to_previous_text_run(
485                info,
486                &document_selection,
487                &new_utf8_range,
488                &new_character_range,
489            )
490            .is_break()
491        {
492            return;
493        }
494
495        let current_inline_styles = self.shared_inline_styles();
496        let box_slot = info.node.is_text_node().then(|| info.node.box_slot());
497        let text_run = ArcRefCell::new(TextRun::new(
498            info.into(),
499            current_inline_styles,
500            new_utf8_range,
501            new_character_range,
502            document_selection.unwrap_or_default(),
503            box_slot
504                .as_ref()
505                .and_then(|box_slot| box_slot.take_layout_box_as_text_run()),
506        ));
507        self.inline_items
508            .push(InlineItem::TextRun(text_run.clone()));
509
510        if let Some(box_slot) = box_slot {
511            box_slot.set(LayoutBox::Text(text_run));
512        }
513    }
514
515    fn try_to_push_text_range_to_previous_text_run(
516        &mut self,
517        info: &NodeAndStyleInfo,
518        new_text_selection: &Option<Range<Utf32CodeUnits>>,
519        new_range: &Range<usize>,
520        new_character_range: &Range<usize>,
521    ) -> ControlFlow<()> {
522        // First check to see if the last item was actually a text run.
523        let Some(InlineItem::TextRun(text_run_arc)) = self.inline_items.last() else {
524            return ControlFlow::Continue(());
525        };
526
527        // Currently to merge two text runs the styles need to be the same.
528        if !text_run_arc
529            .borrow()
530            .inline_styles
531            .ptr_eq(&self.shared_inline_styles())
532        {
533            return ControlFlow::Continue(());
534        }
535
536        let mut text_run = text_run_arc.borrow_mut();
537        if let Some(next_text_selection) = new_text_selection {
538            if !text_run.document_selection.is_empty() {
539                // If both the new and old text had selections, they are only compatible
540                // if the old selection extends to the start of the new selection
541                if text_run.document_selection.end.0 == next_text_selection.start.0 {
542                    text_run.document_selection.end = next_text_selection.end;
543                } else {
544                    return ControlFlow::Continue(());
545                }
546            } else {
547                // If only the new part of the text run has a selection, we can use it directly.
548                text_run.document_selection = next_text_selection.start..next_text_selection.end;
549            }
550        }
551
552        text_run.text_range.end = new_range.end;
553        text_run.character_range.end = new_character_range.end;
554
555        // If this text node does not have a `TextRun` in the box slot, this means that
556        // it is either new or dirty, which means that the entire `TextRun` just extended
557        // is dirty as well. In this case, never reuse existing shaping results. Clear
558        // all old items to ensure this.
559        let box_slot = info.node.box_slot();
560        let old_text_run = box_slot.take_layout_box_as_text_run();
561        if old_text_run.is_none() {
562            text_run.items.clear();
563        }
564
565        box_slot.set(LayoutBox::Text(text_run_arc.clone()));
566        ControlFlow::Break(())
567    }
568
569    pub(crate) fn enter_display_contents(&mut self, shared_inline_styles: SharedInlineStyles) {
570        self.shared_inline_styles_stack.push(shared_inline_styles);
571    }
572
573    pub(crate) fn leave_display_contents(&mut self) {
574        self.shared_inline_styles_stack.pop();
575    }
576
577    /// Finish the current inline formatting context, returning [`None`] if the context was empty.
578    pub(crate) fn finish(
579        self,
580        layout_context: &LayoutContext,
581        has_first_formatted_line: bool,
582        is_single_line_text_input: bool,
583        default_bidi_level: Level,
584    ) -> Option<InlineFormattingContext> {
585        if self.is_empty {
586            return None;
587        }
588
589        assert!(self.inline_box_stack.is_empty());
590        assert_eq!(
591            self.offset_map.total_final_size().0,
592            self.current_character_offset
593        );
594
595        Some(InlineFormattingContext::new_with_builder(
596            self,
597            layout_context,
598            has_first_formatted_line,
599            is_single_line_text_input,
600            default_bidi_level,
601        ))
602    }
603}
604
605/// Computes the range of the first letter.
606///
607/// The range includes any preceding punctuation and white space, and any trailing punctuation. Any
608/// non-punctuation following the letter/number/symbol of first-letter ends the range. Intervening
609/// spaces within trailing punctuation are not supported yet.
610///
611/// If the resulting range is empty, no compatible first-letter text was found.
612///
613/// <https://drafts.csswg.org/css-pseudo/#first-letter-pattern>
614fn first_letter_range(text: &str) -> Range<usize> {
615    enum State {
616        /// All characters that precede the `PrecedingWhitespaceAndPunctuation` state.
617        Start,
618        /// All preceding punctuation and intervening whitepace that precedes the `Lns` state.
619        PrecedingPunctuation,
620        /// Unicode general category L: letter, N: number and S: symbol
621        Lns,
622        /// All punctuation (but no whitespace or other characters), that
623        /// come after the `Lns` state.
624        TrailingPunctuation,
625    }
626
627    let mut start = 0;
628    let mut state = State::Start;
629    for (index, character) in text.char_indices() {
630        match &mut state {
631            State::Start => {
632                if character.is_letter() || character.is_number() || character.is_symbol() {
633                    start = index;
634                    state = State::Lns;
635                } else if character.is_punctuation() {
636                    start = index;
637                    state = State::PrecedingPunctuation
638                }
639            },
640            State::PrecedingPunctuation => {
641                if character.is_letter() || character.is_number() || character.is_symbol() {
642                    state = State::Lns;
643                } else if !character.is_separator_space() && !character.is_punctuation() {
644                    return 0..0;
645                }
646            },
647            State::Lns => {
648                // TODO: Implement support for intervening spaces
649                // <https://drafts.csswg.org/css-pseudo/#first-letter-pattern>
650                if character.is_punctuation() &&
651                    !character.is_punctuation_open() &&
652                    !character.is_punctuation_dash()
653                {
654                    state = State::TrailingPunctuation;
655                } else {
656                    return start..index;
657                }
658            },
659            State::TrailingPunctuation => {
660                // TODO: Implement support for intervening spaces
661                // <https://drafts.csswg.org/css-pseudo/#first-letter-pattern>
662                if character.is_punctuation() &&
663                    !character.is_punctuation_open() &&
664                    !character.is_punctuation_dash()
665                {
666                    continue;
667                } else {
668                    return start..index;
669                }
670            },
671        }
672    }
673
674    match state {
675        State::Start | State::PrecedingPunctuation => 0..0,
676        State::Lns | State::TrailingPunctuation => start..text.len(),
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    fn assert_first_letter_eq(text: &str, expected: &str) {
685        let range = first_letter_range(text);
686        assert_eq!(&text[range], expected);
687    }
688
689    #[test]
690    fn test_first_letter_range() {
691        // All spaces
692        assert_first_letter_eq("", "");
693        assert_first_letter_eq("  ", "");
694
695        // Spaces and punctuation only
696        assert_first_letter_eq("(", "");
697        assert_first_letter_eq(" (", "");
698        assert_first_letter_eq("( ", "");
699        assert_first_letter_eq("()", "");
700
701        // Invalid chars
702        assert_first_letter_eq("\u{0903}", "");
703
704        // First letter only
705        assert_first_letter_eq("A", "A");
706        assert_first_letter_eq(" A", "A");
707        assert_first_letter_eq("A ", "A");
708        assert_first_letter_eq(" A ", "A");
709
710        // Word
711        assert_first_letter_eq("App", "A");
712        assert_first_letter_eq(" App", "A");
713        assert_first_letter_eq("App ", "A");
714
715        // Preceding punctuation(s), intervening spaces and first letter
716        assert_first_letter_eq(r#""A"#, r#""A"#);
717        assert_first_letter_eq(r#" "A"#, r#""A"#);
718        assert_first_letter_eq(r#""A "#, r#""A"#);
719        assert_first_letter_eq(r#"" A"#, r#"" A"#);
720        assert_first_letter_eq(r#" "A "#, r#""A"#);
721        assert_first_letter_eq(r#"("A"#, r#"("A"#);
722        assert_first_letter_eq(r#" ("A"#, r#"("A"#);
723        assert_first_letter_eq(r#"( "A"#, r#"( "A"#);
724        assert_first_letter_eq(r#"[ ( "A"#, r#"[ ( "A"#);
725
726        // First letter and succeeding punctuation(s)
727        // TODO: modify test cases when intervening spaces in succeeding puntuations is supported
728        assert_first_letter_eq(r#"A""#, r#"A""#);
729        assert_first_letter_eq(r#"A" "#, r#"A""#);
730        assert_first_letter_eq(r#"A)]"#, r#"A)]"#);
731        assert_first_letter_eq(r#"A" )]"#, r#"A""#);
732        assert_first_letter_eq(r#"A)] >"#, r#"A)]"#);
733
734        // All
735        assert_first_letter_eq(r#" ("A" )]"#, r#"("A""#);
736        assert_first_letter_eq(r#" ("A")] >"#, r#"("A")]"#);
737
738        // Non ASCII chars
739        assert_first_letter_eq("一", "一");
740        assert_first_letter_eq(" 一 ", "一");
741        assert_first_letter_eq("一二三", "一");
742        assert_first_letter_eq(" 一二三 ", "一");
743        assert_first_letter_eq("(一二三)", "(一");
744        assert_first_letter_eq(" (一二三) ", "(一");
745        assert_first_letter_eq("((一", "((一");
746        assert_first_letter_eq(" ( (一", "( (一");
747        assert_first_letter_eq("一)", "一)");
748        assert_first_letter_eq("一))", "一))");
749        assert_first_letter_eq("一) )", "一)");
750    }
751}