aboutsummaryrefslogtreecommitdiffstats
path: root/components/layout/flow/inline/construct.rs
diff options
context:
space:
mode:
authorMartin Robinson <mrobinson@igalia.com>2025-04-19 12:17:03 +0200
committerGitHub <noreply@github.com>2025-04-19 10:17:03 +0000
commit7787cab521ccc6b4d8533ebe9b45563046e0463d (patch)
treed1277fa3846f24cc99859310da3d7f099c73bfc5 /components/layout/flow/inline/construct.rs
parent3ab5b8c4472129798b63cfb40b63ae672763b653 (diff)
downloadservo-7787cab521ccc6b4d8533ebe9b45563046e0463d.tar.gz
servo-7787cab521ccc6b4d8533ebe9b45563046e0463d.zip
layout: Combine `layout_2020` and `layout_thread_2020` into a crate called `layout` (#36613)
Now that legacy layout has been removed, the name `layout_2020` doesn't make much sense any longer, also it's 2025 now for better or worse. The split between the "layout thread" and "layout" also doesn't make as much sense since layout doesn't run on it's own thread. There's a possibility that it will in the future, but that should be something that the user of the crate controls rather than layout iself. This is part of the larger layout interface cleanup and optimization that @Looriool and I are doing. Testing: Covered by existing tests as this is just code movement. Signed-off-by: Martin Robinson <mrobinson@igalia.com>
Diffstat (limited to 'components/layout/flow/inline/construct.rs')
-rw-r--r--components/layout/flow/inline/construct.rs636
1 files changed, 636 insertions, 0 deletions
diff --git a/components/layout/flow/inline/construct.rs b/components/layout/flow/inline/construct.rs
new file mode 100644
index 00000000000..7c668751ef6
--- /dev/null
+++ b/components/layout/flow/inline/construct.rs
@@ -0,0 +1,636 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+use std::borrow::Cow;
+use std::char::{ToLowercase, ToUppercase};
+
+use icu_segmenter::WordSegmenter;
+use servo_arc::Arc;
+use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
+use style::values::specified::text::TextTransformCase;
+use unicode_bidi::Level;
+
+use super::text_run::TextRun;
+use super::{InlineBox, InlineBoxIdentifier, InlineBoxes, InlineFormattingContext, InlineItem};
+use crate::PropagatedBoxTreeData;
+use crate::cell::ArcRefCell;
+use crate::context::LayoutContext;
+use crate::dom::NodeExt;
+use crate::dom_traversal::NodeAndStyleInfo;
+use crate::flow::float::FloatBox;
+use crate::formatting_contexts::IndependentFormattingContext;
+use crate::positioned::AbsolutelyPositionedBox;
+use crate::style_ext::ComputedValuesExt;
+
+#[derive(Default)]
+pub(crate) struct InlineFormattingContextBuilder {
+ /// The collection of text strings that make up this [`InlineFormattingContext`] under
+ /// construction.
+ pub text_segments: Vec<String>,
+
+ /// The current offset in the final text string of this [`InlineFormattingContext`],
+ /// used to properly set the text range of new [`InlineItem::TextRun`]s.
+ current_text_offset: usize,
+
+ /// Whether the last processed node ended with whitespace. This is used to
+ /// implement rule 4 of <https://www.w3.org/TR/css-text-3/#collapse>:
+ ///
+ /// > Any collapsible space immediately following another collapsible space—even one
+ /// > outside the boundary of the inline containing that space, provided both spaces are
+ /// > within the same inline formatting context—is collapsed to have zero advance width.
+ /// > (It is invisible, but retains its soft wrap opportunity, if any.)
+ last_inline_box_ended_with_collapsible_white_space: bool,
+
+ /// Whether or not the current state of the inline formatting context is on a word boundary
+ /// for the purposes of `text-transform: capitalize`.
+ on_word_boundary: bool,
+
+ /// Whether or not this inline formatting context will contain floats.
+ pub contains_floats: bool,
+
+ /// The current list of [`InlineItem`]s in this [`InlineFormattingContext`] under
+ /// construction. This is stored in a flat list to make it easy to access the last
+ /// item.
+ pub inline_items: Vec<ArcRefCell<InlineItem>>,
+
+ /// The current [`InlineBox`] tree of this [`InlineFormattingContext`] under construction.
+ pub inline_boxes: InlineBoxes,
+
+ /// The ongoing stack of inline boxes stack of the builder.
+ ///
+ /// Contains all the currently ongoing inline boxes we entered so far.
+ /// The traversal is at all times as deep in the tree as this stack is,
+ /// which is why the code doesn't need to keep track of the actual
+ /// container root (see `handle_inline_level_element`).
+ ///
+ /// When an inline box ends, it's removed from this stack.
+ inline_box_stack: Vec<InlineBoxIdentifier>,
+
+ /// Whether or not the inline formatting context under construction has any
+ /// uncollapsible text content.
+ pub has_uncollapsible_text_content: bool,
+}
+
+impl InlineFormattingContextBuilder {
+ pub(crate) fn new() -> Self {
+ // For the purposes of `text-transform: capitalize` the start of the IFC is a word boundary.
+ Self {
+ on_word_boundary: true,
+ ..Default::default()
+ }
+ }
+
+ pub(crate) fn currently_processing_inline_box(&self) -> bool {
+ !self.inline_box_stack.is_empty()
+ }
+
+ fn push_control_character_string(&mut self, string_to_push: &str) {
+ self.text_segments.push(string_to_push.to_owned());
+ self.current_text_offset += string_to_push.len();
+ }
+
+ /// Return true if this [`InlineFormattingContextBuilder`] is empty for the purposes of ignoring
+ /// during box tree construction. An IFC is empty if it only contains TextRuns with
+ /// completely collapsible whitespace. When that happens it can be ignored completely.
+ pub(crate) fn is_empty(&self) -> bool {
+ if self.has_uncollapsible_text_content {
+ return false;
+ }
+
+ if !self.inline_box_stack.is_empty() {
+ return false;
+ }
+
+ fn inline_level_box_is_empty(inline_level_box: &InlineItem) -> bool {
+ match inline_level_box {
+ InlineItem::StartInlineBox(_) => false,
+ InlineItem::EndInlineBox => false,
+ // Text content is handled by `self.has_uncollapsible_text` content above in order
+ // to avoid having to iterate through the character once again.
+ InlineItem::TextRun(_) => true,
+ InlineItem::OutOfFlowAbsolutelyPositionedBox(..) => false,
+ InlineItem::OutOfFlowFloatBox(_) => false,
+ InlineItem::Atomic(..) => false,
+ }
+ }
+
+ self.inline_items
+ .iter()
+ .all(|inline_level_box| inline_level_box_is_empty(&inline_level_box.borrow()))
+ }
+
+ pub(crate) fn push_atomic(
+ &mut self,
+ independent_formatting_context: IndependentFormattingContext,
+ ) -> ArcRefCell<InlineItem> {
+ let inline_level_box = ArcRefCell::new(InlineItem::Atomic(
+ Arc::new(independent_formatting_context),
+ self.current_text_offset,
+ Level::ltr(), /* This will be assigned later if necessary. */
+ ));
+ self.inline_items.push(inline_level_box.clone());
+
+ // Push an object replacement character for this atomic, which will ensure that the line breaker
+ // inserts a line breaking opportunity here.
+ self.push_control_character_string("\u{fffc}");
+
+ self.last_inline_box_ended_with_collapsible_white_space = false;
+ self.on_word_boundary = true;
+
+ inline_level_box
+ }
+
+ pub(crate) fn push_absolutely_positioned_box(
+ &mut self,
+ absolutely_positioned_box: AbsolutelyPositionedBox,
+ ) -> ArcRefCell<InlineItem> {
+ let absolutely_positioned_box = ArcRefCell::new(absolutely_positioned_box);
+ let inline_level_box = ArcRefCell::new(InlineItem::OutOfFlowAbsolutelyPositionedBox(
+ absolutely_positioned_box,
+ self.current_text_offset,
+ ));
+
+ self.inline_items.push(inline_level_box.clone());
+ inline_level_box
+ }
+
+ pub(crate) fn push_float_box(&mut self, float_box: FloatBox) -> ArcRefCell<InlineItem> {
+ let inline_level_box = ArcRefCell::new(InlineItem::OutOfFlowFloatBox(Arc::new(float_box)));
+ self.inline_items.push(inline_level_box.clone());
+ self.contains_floats = true;
+ inline_level_box
+ }
+
+ pub(crate) fn start_inline_box(&mut self, inline_box: InlineBox) -> ArcRefCell<InlineItem> {
+ self.push_control_character_string(inline_box.base.style.bidi_control_chars().0);
+
+ let (identifier, inline_box) = self.inline_boxes.start_inline_box(inline_box);
+ let inline_level_box = ArcRefCell::new(InlineItem::StartInlineBox(inline_box));
+ self.inline_items.push(inline_level_box.clone());
+ self.inline_box_stack.push(identifier);
+ inline_level_box
+ }
+
+ pub(crate) fn end_inline_box(&mut self) -> ArcRefCell<InlineBox> {
+ let identifier = self.end_inline_box_internal();
+ let inline_level_box = self.inline_boxes.get(&identifier);
+ inline_level_box.borrow_mut().is_last_fragment = true;
+
+ self.push_control_character_string(
+ inline_level_box.borrow().base.style.bidi_control_chars().1,
+ );
+
+ inline_level_box
+ }
+
+ fn end_inline_box_internal(&mut self) -> InlineBoxIdentifier {
+ let identifier = self
+ .inline_box_stack
+ .pop()
+ .expect("Ended non-existent inline box");
+ self.inline_items
+ .push(ArcRefCell::new(InlineItem::EndInlineBox));
+
+ self.inline_boxes.end_inline_box(identifier);
+ identifier
+ }
+
+ pub(crate) fn push_text<'dom, Node: NodeExt<'dom>>(
+ &mut self,
+ text: Cow<'dom, str>,
+ info: &NodeAndStyleInfo<Node>,
+ ) {
+ let white_space_collapse = info.style.clone_white_space_collapse();
+ let collapsed = WhitespaceCollapse::new(
+ text.chars(),
+ white_space_collapse,
+ self.last_inline_box_ended_with_collapsible_white_space,
+ );
+
+ // TODO: Not all text transforms are about case, this logic should stop ignoring
+ // TextTransform::FULL_WIDTH and TextTransform::FULL_SIZE_KANA.
+ let text_transform = info.style.clone_text_transform().case();
+ let capitalized_text: String;
+ let char_iterator: Box<dyn Iterator<Item = char>> = match text_transform {
+ TextTransformCase::None => Box::new(collapsed),
+ TextTransformCase::Capitalize => {
+ // `TextTransformation` doesn't support capitalization, so we must capitalize the whole
+ // string at once and make a copy. Here `on_word_boundary` indicates whether or not the
+ // inline formatting context as a whole is on a word boundary. This is different from
+ // `last_inline_box_ended_with_collapsible_white_space` because the word boundaries are
+ // between atomic inlines and at the start of the IFC, and because preserved spaces
+ // are a word boundary.
+ let collapsed_string: String = collapsed.collect();
+ capitalized_text = capitalize_string(&collapsed_string, self.on_word_boundary);
+ Box::new(capitalized_text.chars())
+ },
+ _ => {
+ // If `text-transform` is active, wrap the `WhitespaceCollapse` iterator in
+ // a `TextTransformation` iterator.
+ Box::new(TextTransformation::new(collapsed, text_transform))
+ },
+ };
+
+ let white_space_collapse = info.style.clone_white_space_collapse();
+ let new_text: String = char_iterator
+ .inspect(|&character| {
+ self.has_uncollapsible_text_content |= matches!(
+ white_space_collapse,
+ WhiteSpaceCollapse::Preserve | WhiteSpaceCollapse::BreakSpaces
+ ) || !character.is_ascii_whitespace() ||
+ (character == '\n' && white_space_collapse != WhiteSpaceCollapse::Collapse);
+ })
+ .collect();
+
+ if new_text.is_empty() {
+ return;
+ }
+
+ let selection_range = info.get_selection_range();
+ let selected_style = info.get_selected_style();
+
+ if let Some(last_character) = new_text.chars().next_back() {
+ self.on_word_boundary = last_character.is_whitespace();
+ self.last_inline_box_ended_with_collapsible_white_space =
+ self.on_word_boundary && white_space_collapse != WhiteSpaceCollapse::Preserve;
+ }
+
+ let new_range = self.current_text_offset..self.current_text_offset + new_text.len();
+ self.current_text_offset = new_range.end;
+ self.text_segments.push(new_text);
+
+ if let Some(inline_item) = self.inline_items.last() {
+ if let InlineItem::TextRun(text_run) = &mut *inline_item.borrow_mut() {
+ text_run.borrow_mut().text_range.end = new_range.end;
+ return;
+ }
+ }
+
+ self.inline_items
+ .push(ArcRefCell::new(InlineItem::TextRun(ArcRefCell::new(
+ TextRun::new(
+ info.into(),
+ info.style.clone(),
+ new_range,
+ selection_range,
+ selected_style,
+ ),
+ ))));
+ }
+
+ pub(crate) fn split_around_block_and_finish(
+ &mut self,
+ layout_context: &LayoutContext,
+ propagated_data: PropagatedBoxTreeData,
+ has_first_formatted_line: bool,
+ default_bidi_level: Level,
+ ) -> Option<InlineFormattingContext> {
+ if self.is_empty() {
+ return None;
+ }
+
+ // Create a new inline builder which will be active after the block splits this inline formatting
+ // context. It has the same inline box structure as this builder, except the boxes are
+ // marked as not being the first fragment. No inline content is carried over to this new
+ // builder.
+ let mut new_builder = InlineFormattingContextBuilder::new();
+ for identifier in self.inline_box_stack.iter() {
+ new_builder.start_inline_box(
+ self.inline_boxes
+ .get(identifier)
+ .borrow()
+ .split_around_block(),
+ );
+ }
+ let mut inline_builder_from_before_split = std::mem::replace(self, new_builder);
+
+ // End all ongoing inline boxes in the first builder, but ensure that they are not
+ // marked as the final fragments, so that they do not get inline end margin, borders,
+ // and padding.
+ while !inline_builder_from_before_split.inline_box_stack.is_empty() {
+ inline_builder_from_before_split.end_inline_box_internal();
+ }
+
+ inline_builder_from_before_split.finish(
+ layout_context,
+ propagated_data,
+ has_first_formatted_line,
+ /* is_single_line_text_input = */ false,
+ default_bidi_level,
+ )
+ }
+
+ /// Finish the current inline formatting context, returning [`None`] if the context was empty.
+ pub(crate) fn finish(
+ &mut self,
+ layout_context: &LayoutContext,
+ propagated_data: PropagatedBoxTreeData,
+ has_first_formatted_line: bool,
+ is_single_line_text_input: bool,
+ default_bidi_level: Level,
+ ) -> Option<InlineFormattingContext> {
+ if self.is_empty() {
+ return None;
+ }
+
+ let old_builder = std::mem::replace(self, InlineFormattingContextBuilder::new());
+ assert!(old_builder.inline_box_stack.is_empty());
+
+ Some(InlineFormattingContext::new_with_builder(
+ old_builder,
+ layout_context,
+ propagated_data,
+ has_first_formatted_line,
+ is_single_line_text_input,
+ default_bidi_level,
+ ))
+ }
+}
+
+fn preserve_segment_break() -> bool {
+ true
+}
+
+pub struct WhitespaceCollapse<InputIterator> {
+ char_iterator: InputIterator,
+ white_space_collapse: WhiteSpaceCollapse,
+
+ /// Whether or not we should collapse white space completely at the start of the string.
+ /// This is true when the last character handled in our owning [`super::InlineFormattingContext`]
+ /// was collapsible white space.
+ remove_collapsible_white_space_at_start: bool,
+
+ /// Whether or not the last character produced was newline. There is special behavior
+ /// we do after each newline.
+ following_newline: bool,
+
+ /// Whether or not we have seen any non-white space characters, indicating that we are not
+ /// in a collapsible white space section at the beginning of the string.
+ have_seen_non_white_space_characters: bool,
+
+ /// Whether the last character that we processed was a non-newline white space character. When
+ /// collapsing white space we need to wait until the next non-white space character or the end
+ /// of the string to push a single white space.
+ inside_white_space: bool,
+
+ /// When we enter a collapsible white space region, we may need to wait to produce a single
+ /// white space character as soon as we encounter a non-white space character. When that
+ /// happens we queue up the non-white space character for the next iterator call.
+ character_pending_to_return: Option<char>,
+}
+
+impl<InputIterator> WhitespaceCollapse<InputIterator> {
+ pub fn new(
+ char_iterator: InputIterator,
+ white_space_collapse: WhiteSpaceCollapse,
+ trim_beginning_white_space: bool,
+ ) -> Self {
+ Self {
+ char_iterator,
+ white_space_collapse,
+ remove_collapsible_white_space_at_start: trim_beginning_white_space,
+ inside_white_space: false,
+ following_newline: false,
+ have_seen_non_white_space_characters: false,
+ character_pending_to_return: None,
+ }
+ }
+
+ fn is_leading_trimmed_white_space(&self) -> bool {
+ !self.have_seen_non_white_space_characters && self.remove_collapsible_white_space_at_start
+ }
+
+ /// Whether or not we need to produce a space character if the next character is not a newline
+ /// and not white space. This happens when we are exiting a section of white space and we
+ /// waited to produce a single space character for the entire section of white space (but
+ /// not following or preceding a newline).
+ fn need_to_produce_space_character_after_white_space(&self) -> bool {
+ self.inside_white_space && !self.following_newline && !self.is_leading_trimmed_white_space()
+ }
+}
+
+impl<InputIterator> Iterator for WhitespaceCollapse<InputIterator>
+where
+ InputIterator: Iterator<Item = char>,
+{
+ type Item = char;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ // Point 4.1.1 first bullet:
+ // > If white-space is set to normal, nowrap, or pre-line, whitespace
+ // > characters are considered collapsible
+ // If whitespace is not considered collapsible, it is preserved entirely, which
+ // means that we can simply return the input string exactly.
+ if self.white_space_collapse == WhiteSpaceCollapse::Preserve ||
+ self.white_space_collapse == WhiteSpaceCollapse::BreakSpaces
+ {
+ // From <https://drafts.csswg.org/css-text-3/#white-space-processing>:
+ // > Carriage returns (U+000D) are treated identically to spaces (U+0020) in all respects.
+ //
+ // In the non-preserved case these are converted to space below.
+ return match self.char_iterator.next() {
+ Some('\r') => Some(' '),
+ next => next,
+ };
+ }
+
+ if let Some(character) = self.character_pending_to_return.take() {
+ self.inside_white_space = false;
+ self.have_seen_non_white_space_characters = true;
+ self.following_newline = false;
+ return Some(character);
+ }
+
+ while let Some(character) = self.char_iterator.next() {
+ // Don't push non-newline whitespace immediately. Instead wait to push it until we
+ // know that it isn't followed by a newline. See `push_pending_whitespace_if_needed`
+ // above.
+ if character.is_ascii_whitespace() && character != '\n' {
+ self.inside_white_space = true;
+ continue;
+ }
+
+ // Point 4.1.1:
+ // > 2. Collapsible segment breaks are transformed for rendering according to the
+ // > segment break transformation rules.
+ if character == '\n' {
+ // From <https://drafts.csswg.org/css-text-3/#line-break-transform>
+ // (4.1.3 -- the segment break transformation rules):
+ //
+ // > When white-space is pre, pre-wrap, or pre-line, segment breaks are not
+ // > collapsible and are instead transformed into a preserved line feed"
+ if self.white_space_collapse != WhiteSpaceCollapse::Collapse {
+ self.inside_white_space = false;
+ self.following_newline = true;
+ return Some(character);
+
+ // Point 4.1.3:
+ // > 1. First, any collapsible segment break immediately following another
+ // > collapsible segment break is removed.
+ // > 2. Then any remaining segment break is either transformed into a space (U+0020)
+ // > or removed depending on the context before and after the break.
+ } else if !self.following_newline &&
+ preserve_segment_break() &&
+ !self.is_leading_trimmed_white_space()
+ {
+ self.inside_white_space = false;
+ self.following_newline = true;
+ return Some(' ');
+ } else {
+ self.following_newline = true;
+ continue;
+ }
+ }
+
+ // Point 4.1.1:
+ // > 2. Any sequence of collapsible spaces and tabs immediately preceding or
+ // > following a segment break is removed.
+ // > 3. Every collapsible tab is converted to a collapsible space (U+0020).
+ // > 4. Any collapsible space immediately following another collapsible space—even
+ // > one outside the boundary of the inline containing that space, provided both
+ // > spaces are within the same inline formatting context—is collapsed to have zero
+ // > advance width.
+ if self.need_to_produce_space_character_after_white_space() {
+ self.inside_white_space = false;
+ self.character_pending_to_return = Some(character);
+ return Some(' ');
+ }
+
+ self.inside_white_space = false;
+ self.have_seen_non_white_space_characters = true;
+ self.following_newline = false;
+ return Some(character);
+ }
+
+ if self.need_to_produce_space_character_after_white_space() {
+ self.inside_white_space = false;
+ return Some(' ');
+ }
+
+ None
+ }
+
+ fn size_hint(&self) -> (usize, Option<usize>) {
+ self.char_iterator.size_hint()
+ }
+
+ fn count(self) -> usize
+ where
+ Self: Sized,
+ {
+ self.char_iterator.count()
+ }
+}
+
+enum PendingCaseConversionResult {
+ Uppercase(ToUppercase),
+ Lowercase(ToLowercase),
+}
+
+impl PendingCaseConversionResult {
+ fn next(&mut self) -> Option<char> {
+ match self {
+ PendingCaseConversionResult::Uppercase(to_uppercase) => to_uppercase.next(),
+ PendingCaseConversionResult::Lowercase(to_lowercase) => to_lowercase.next(),
+ }
+ }
+}
+
+/// This is an interator that consumes a char iterator and produces character transformed
+/// by the given CSS `text-transform` value. It currently does not support
+/// `text-transform: capitalize` because Unicode segmentation libraries do not support
+/// streaming input one character at a time.
+pub struct TextTransformation<InputIterator> {
+ /// The input character iterator.
+ char_iterator: InputIterator,
+ /// The `text-transform` value to use.
+ text_transform: TextTransformCase,
+ /// If an uppercasing or lowercasing produces more than one character, this
+ /// caches them so that they can be returned in subsequent iterator calls.
+ pending_case_conversion_result: Option<PendingCaseConversionResult>,
+}
+
+impl<InputIterator> TextTransformation<InputIterator> {
+ pub fn new(char_iterator: InputIterator, text_transform: TextTransformCase) -> Self {
+ Self {
+ char_iterator,
+ text_transform,
+ pending_case_conversion_result: None,
+ }
+ }
+}
+
+impl<InputIterator> Iterator for TextTransformation<InputIterator>
+where
+ InputIterator: Iterator<Item = char>,
+{
+ type Item = char;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ if let Some(character) = self
+ .pending_case_conversion_result
+ .as_mut()
+ .and_then(|result| result.next())
+ {
+ return Some(character);
+ }
+ self.pending_case_conversion_result = None;
+
+ for character in self.char_iterator.by_ref() {
+ match self.text_transform {
+ TextTransformCase::None => return Some(character),
+ TextTransformCase::Uppercase => {
+ let mut pending_result =
+ PendingCaseConversionResult::Uppercase(character.to_uppercase());
+ if let Some(character) = pending_result.next() {
+ self.pending_case_conversion_result = Some(pending_result);
+ return Some(character);
+ }
+ },
+ TextTransformCase::Lowercase => {
+ let mut pending_result =
+ PendingCaseConversionResult::Lowercase(character.to_lowercase());
+ if let Some(character) = pending_result.next() {
+ self.pending_case_conversion_result = Some(pending_result);
+ return Some(character);
+ }
+ },
+ // `text-transform: capitalize` currently cannot work on a per-character basis,
+ // so must be handled outside of this iterator.
+ TextTransformCase::Capitalize => return Some(character),
+ }
+ }
+ None
+ }
+}
+
+/// Given a string and whether the start of the string represents a word boundary, create a copy of
+/// the string with letters after word boundaries capitalized.
+fn capitalize_string(string: &str, allow_word_at_start: bool) -> String {
+ let mut output_string = String::new();
+ output_string.reserve(string.len());
+
+ let word_segmenter = WordSegmenter::new_auto();
+ let mut bounds = word_segmenter.segment_str(string).peekable();
+ let mut byte_index = 0;
+ for character in string.chars() {
+ let current_byte_index = byte_index;
+ byte_index += character.len_utf8();
+
+ if let Some(next_index) = bounds.peek() {
+ if *next_index == current_byte_index {
+ bounds.next();
+
+ if current_byte_index != 0 || allow_word_at_start {
+ output_string.extend(character.to_uppercase());
+ continue;
+ }
+ }
+ }
+
+ output_string.push(character);
+ }
+
+ output_string
+}