From b37a7716db64231d4229a47a4372909acb1cd083 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 26 Sep 2026 02:53:25 +0000 Subject: [PATCH 01/13] Implement text-transform: capitalize; use ICU4X case mapping for all case transforms --- Cargo.lock | 27 ++ Cargo.toml | 6 + packages/blitz-dom/Cargo.toml | 6 + packages/blitz-dom/src/layout/construct.rs | 79 ++++-- packages/blitz-dom/src/layout/mod.rs | 1 + .../blitz-dom/src/layout/text_transform.rs | 268 ++++++++++++++++++ 6 files changed, 361 insertions(+), 26 deletions(-) create mode 100644 packages/blitz-dom/src/layout/text_transform.rs diff --git a/Cargo.lock b/Cargo.lock index 8a742ac634..ef154643f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -917,6 +917,10 @@ dependencies = [ "debug_timer 0.1.3", "euclid", "html-escape", + "icu_casemap", + "icu_locale_core", + "icu_properties", + "icu_segmenter", "image", "keyboard-types 0.7.0", "kurbo", @@ -4086,6 +4090,28 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" +[[package]] +name = "icu_casemap" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2edfd120d8445de853cf53a1abec916f638f933a70a6f4bf8f7e2d7b7f884e9" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059fcda0fc8d33effc6633a8cf0d8a3c17caf7d7c91918fba9918744f501e4e8" + [[package]] name = "icu_collections" version = "2.3.0" @@ -4094,6 +4120,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", diff --git a/Cargo.toml b/Cargo.toml index a5726ca19b..ebbc185f36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -192,6 +192,12 @@ rayon = "1" test-that = "0.5.2" thread_local = "1" +# Unicode (ICU4X) +icu_casemap = { version = "2.3", default-features = false, features = ["compiled_data"] } +icu_locale_core = { version = "2.3", default-features = false } +icu_properties = { version = "2.3", default-features = false, features = ["compiled_data"] } +icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] } + [profile.profile] inherits = "release" debug = true diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index ab86c3b719..545aec5f43 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -69,6 +69,12 @@ linebender_resource_handle = { workspace = true } color = { workspace = true } kurbo = { workspace = true } +# Unicode +icu_casemap = { workspace = true } +icu_locale_core = { workspace = true } +icu_properties = { workspace = true } +icu_segmenter = { workspace = true } + # Other dependencies slotmap = { workspace = true } bitflags = { workspace = true } diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index f34387fd1e..79b4faeb05 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -12,7 +12,7 @@ use style::{ data::ElementData as StyloElementData, shared_lock::StylesheetGuards, values::{ - computed::{Content, ContentItem, Display, Float, TextTransform}, + computed::{Content, ContentItem, Display, Float}, specified::box_::{DisplayInside, DisplayOutside}, }, }; @@ -36,6 +36,7 @@ use super::{ list::{BULLET_FONT_FAMILY, collect_list_item_children}, replaced::is_inline_box_element, table::build_table_context, + text_transform::{CaseTransform, TextTransformer}, }; const DUMMY_NAME: QualName = qual_name!("div", html); @@ -1066,9 +1067,10 @@ pub(crate) fn build_inline_layout_into( } let text_transform = root_node_style - .as_ref() - .map(|s| s.clone_text_transform() & TextTransform::CASE_TRANSFORMS) - .unwrap_or(TextTransform::NONE); + .as_deref() + .map(|s| CaseTransform::from_style(s)) + .unwrap_or(CaseTransform::NONE); + let mut text_transformer = TextTransformer::default(); // Render position-inside list items if let Some(ListItemLayout { @@ -1095,13 +1097,31 @@ pub(crate) fn build_inline_layout_into( }; if let Some(before_id) = root_node.before() { - build_inline_layout_recursive(&mut builder, nodes, before_id, text_transform); + build_inline_layout_recursive( + &mut builder, + &mut text_transformer, + nodes, + before_id, + &text_transform, + ); } for child_id in root_node.children.iter().copied() { - build_inline_layout_recursive(&mut builder, nodes, child_id, text_transform); + build_inline_layout_recursive( + &mut builder, + &mut text_transformer, + nodes, + child_id, + &text_transform, + ); } if let Some(after_id) = root_node.after() { - build_inline_layout_recursive(&mut builder, nodes, after_id, text_transform); + build_inline_layout_recursive( + &mut builder, + &mut text_transformer, + nodes, + after_id, + &text_transform, + ); } text_layout.text = builder.build_into(&mut text_layout.layout); @@ -1109,9 +1129,10 @@ pub(crate) fn build_inline_layout_into( fn build_inline_layout_recursive( builder: &mut TreeBuilder, + text_transformer: &mut TextTransformer, nodes: &crate::NodeTree, node_id: NodeId, - parent_text_transform: TextTransform, + parent_text_transform: &CaseTransform, ) { let node = &nodes[node_id]; @@ -1119,8 +1140,8 @@ pub(crate) fn build_inline_layout_into( let style = style.as_ref(); let text_transform = style - .map(|s| s.clone_text_transform() & TextTransform::CASE_TRANSFORMS) - .unwrap_or(TextTransform::NONE); + .map(|s| CaseTransform::from_style(s)) + .unwrap_or(CaseTransform::NONE); match &node.data { NodeData::Element(element_data) | NodeData::AnonymousBlock(element_data) => { @@ -1168,7 +1189,13 @@ pub(crate) fn build_inline_layout_into( ); for child_id in node.children.iter().copied() { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); - build_inline_layout_recursive(builder, nodes, child_id, text_transform); + build_inline_layout_recursive( + builder, + text_transformer, + nodes, + child_id, + &text_transform, + ); } builder.pop_style_span(); } @@ -1176,6 +1203,9 @@ pub(crate) fn build_inline_layout_into( let tag_name = &element_data.name.local; if is_inline_box_element(tag_name) { + if box_kind == InlineBoxKind::InFlow { + text_transformer.word_break(); + } builder.push_inline_box(InlineBox { id: node_id.as_u64(), kind: box_kind, @@ -1200,6 +1230,7 @@ pub(crate) fn build_inline_layout_into( ]); builder.push_text("\n"); builder.pop_style_span(); + text_transformer.word_break(); } else { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); let style = node @@ -1215,26 +1246,29 @@ pub(crate) fn build_inline_layout_into( if let Some(before_id) = node.before() { build_inline_layout_recursive( builder, + text_transformer, nodes, before_id, - text_transform, + &text_transform, ); } for child_id in node.children.iter().copied() { build_inline_layout_recursive( builder, + text_transformer, nodes, child_id, - text_transform, + &text_transform, ); } if let Some(after_id) = node.after() { build_inline_layout_recursive( builder, + text_transformer, nodes, after_id, - text_transform, + &text_transform, ); } @@ -1243,6 +1277,9 @@ pub(crate) fn build_inline_layout_into( } // Inline box (_, _) => { + if box_kind == InlineBoxKind::InFlow { + text_transformer.word_break(); + } builder.push_inline_box(InlineBox { id: node_id.as_u64(), kind: box_kind, @@ -1264,18 +1301,8 @@ pub(crate) fn build_inline_layout_into( // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); // dbg!(&data.content); - // TODO: optimize case transforms to be non-allocating - match parent_text_transform { - TextTransform::UPPERCASE => { - builder.push_text(&data.content.to_uppercase()); - } - TextTransform::LOWERCASE => { - builder.push_text(&data.content.to_lowercase()); - } - _ => { - builder.push_text(&data.content); - } - } + let text = text_transformer.transform(&data.content, parent_text_transform); + builder.push_text(&text); } NodeData::Comment { .. } => { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); diff --git a/packages/blitz-dom/src/layout/mod.rs b/packages/blitz-dom/src/layout/mod.rs index 691ce02bb6..5c08bf4b59 100644 --- a/packages/blitz-dom/src/layout/mod.rs +++ b/packages/blitz-dom/src/layout/mod.rs @@ -28,6 +28,7 @@ pub(crate) mod list; pub(crate) mod paint_tree; pub(crate) mod replaced; pub(crate) mod table; +pub(crate) mod text_transform; use self::replaced::{ IntrinsicSizes, ReplacedContext, compute_replaced_layout, is_replaced_element, diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs new file mode 100644 index 0000000000..72ab98a2df --- /dev/null +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -0,0 +1,268 @@ +//! Case mapping for the CSS `text-transform` property. +//! +//! + +use std::borrow::Cow; + +use icu_casemap::options::{LeadingAdjustment, TitlecaseOptions, TrailingCase}; +use icu_casemap::{CaseMapper, CaseMapperBorrowed, TitlecaseMapper, TitlecaseMapperBorrowed}; +use icu_locale_core::LanguageIdentifier; +use icu_properties::props::{GeneralCategory, GeneralCategoryGroup}; +use icu_properties::{CodePointMapData, CodePointMapDataBorrowed}; +use icu_segmenter::{WordSegmenter, WordSegmenterBorrowed, options::WordBreakInvariantOptions}; +use style::properties::ComputedValues; +use style::values::computed::TextTransform; + +const CASE_MAPPER: CaseMapperBorrowed<'static> = CaseMapper::new(); +const TITLECASE_MAPPER: TitlecaseMapperBorrowed<'static> = TitlecaseMapper::new(); +const WORD_SEGMENTER: WordSegmenterBorrowed<'static> = + WordSegmenter::new_for_non_complex_scripts(WordBreakInvariantOptions::default()); +const GENERAL_CATEGORY: CodePointMapDataBorrowed<'static, GeneralCategory> = + CodePointMapData::::new(); + +/// The maximum number of bytes of preceding text kept as context for finding word boundaries. +const MAX_CONTEXT_LEN: usize = 32; + +/// The case transform (and language) that applies to the text content of an element. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct CaseTransform { + kind: TextTransform, + lang: LanguageIdentifier, +} + +impl CaseTransform { + pub(crate) const NONE: Self = Self { + kind: TextTransform::NONE, + lang: LanguageIdentifier::UNKNOWN, + }; + + pub(crate) fn from_style(style: &ComputedValues) -> Self { + let kind = style.clone_text_transform() & TextTransform::CASE_TRANSFORMS; + if kind.is_empty() { + return Self::NONE; + } + let lang = LanguageIdentifier::try_from_str(&style.get_font()._x_lang.0) + .unwrap_or(LanguageIdentifier::UNKNOWN); + Self { kind, lang } + } +} + +/// Applies case transforms to the sequence of text nodes in an inline formatting context. +/// +/// `capitalize` operates on words, which may span multiple text nodes, so the (untransformed) +/// text preceding the current text node is kept as context for word segmentation. Only +/// look-behind is needed: whether a word boundary precedes a letter never depends on +/// the text that follows it. +#[derive(Default)] +pub(crate) struct TextTransformer { + context: String, +} + +impl TextTransformer { + /// Forces a word boundary (e.g. at an atomic inline or a forced line break). + pub(crate) fn word_break(&mut self) { + self.context.clear(); + } + + /// Transforms the content of a text node. Must be called for every text node in the + /// inline formatting context (in order), including those that are not transformed. + pub(crate) fn transform<'a>( + &mut self, + text: &'a str, + transform: &CaseTransform, + ) -> Cow<'a, str> { + let transformed = match transform.kind { + TextTransform::UPPERCASE => CASE_MAPPER.uppercase_to_string(text, &transform.lang), + TextTransform::LOWERCASE => CASE_MAPPER.lowercase_to_string(text, &transform.lang), + TextTransform::CAPITALIZE => Cow::Owned(self.capitalize(text, &transform.lang)), + _ => Cow::Borrowed(text), + }; + self.push_context(text); + transformed + } + + /// Titlecases the first typographic letter unit of each word, leaving other characters unchanged. + fn capitalize(&self, text: &str, lang: &LanguageIdentifier) -> String { + let mut options = TitlecaseOptions::default(); + options.leading_adjustment = Some(LeadingAdjustment::None); + options.trailing_case = Some(TrailingCase::Unchanged); + + let context_len = self.context.len(); + let combined = [self.context.as_str(), text].concat(); + + let mut output = String::with_capacity(text.len()); + let mut segment_start = 0; + for segment_end in WORD_SEGMENTER.segment_str(&combined).skip(1) { + if segment_end > context_len { + let part_start = segment_start.max(context_len); + let part = &combined[part_start..segment_end]; + + // A word that started in a previous text node has already had its first + // letter unit (if any) transformed (or not) as part of that text node. + let first_letter_already_seen = combined[segment_start..part_start] + .chars() + .any(is_typographic_letter_unit); + + match part.find(is_typographic_letter_unit) { + Some(letter_start) if !first_letter_already_seen => { + output.push_str(&part[..letter_start]); + output.push_str(&TITLECASE_MAPPER.titlecase_segment_to_string( + &part[letter_start..], + lang, + options, + )); + } + _ => output.push_str(part), + } + } + segment_start = segment_end; + } + + output + } + + fn push_context(&mut self, text: &str) { + if text.len() >= MAX_CONTEXT_LEN { + self.context.clear(); + let start = ceil_char_boundary(text, text.len() - MAX_CONTEXT_LEN); + self.context.push_str(&text[start..]); + } else { + self.context.push_str(text); + if self.context.len() > MAX_CONTEXT_LEN { + let start = ceil_char_boundary(&self.context, self.context.len() - MAX_CONTEXT_LEN); + self.context.drain(..start); + } + } + } +} + +/// +fn is_typographic_letter_unit(c: char) -> bool { + let category = GENERAL_CATEGORY.get(c); + GeneralCategoryGroup::Letter.contains(category) + || GeneralCategoryGroup::Number.contains(category) +} + +fn ceil_char_boundary(s: &str, mut index: usize) -> usize { + while !s.is_char_boundary(index) { + index += 1; + } + index +} + +#[cfg(test)] +mod tests { + use super::*; + + fn transform(kind: TextTransform, lang: &str, texts: &[&str]) -> Vec { + let transform = CaseTransform { + kind, + lang: LanguageIdentifier::try_from_str(lang).unwrap(), + }; + let mut transformer = TextTransformer::default(); + texts + .iter() + .map(|text| transformer.transform(text, &transform).into_owned()) + .collect() + } + + fn capitalize(texts: &[&str]) -> Vec { + transform(TextTransform::CAPITALIZE, "und", texts) + } + + #[test] + fn capitalize_words() { + assert_eq!(capitalize(&["hello world"]), ["Hello World"]); + assert_eq!(capitalize(&["foo-bar"]), ["Foo-Bar"]); + assert_eq!(capitalize(&["john's apple"]), ["John's Apple"]); + assert_eq!(capitalize(&["foo_bar"]), ["Foo_bar"]); + assert_eq!(capitalize(&["cancel·lar"]), ["Cancel·lar"]); + assert_eq!(capitalize(&["un·e député·e"]), ["Un·e Député·e"]); + assert_eq!(capitalize(&["3rd place"]), ["3rd Place"]); + assert_eq!(capitalize(&["aBC"]), ["ABC"]); + // Circled letters are symbols, not letters + assert_eq!(capitalize(&["ⓐⓐⓐ ⓑⓑⓑ"]), ["ⓐⓐⓐ ⓑⓑⓑ"]); + } + + #[test] + fn capitalize_skips_leading_punctuation() { + assert_eq!( + capitalize(&["({[-–\"«'. Date: Sat, 26 Sep 2026 12:31:27 +0000 Subject: [PATCH 02/13] Use parley's TreeBuilder::text_so_far as capitalize context --- Cargo.lock | 12 +- Cargo.toml | 2 +- packages/blitz-dom/src/layout/construct.rs | 13 +- .../blitz-dom/src/layout/text_transform.rs | 180 ++++++++++-------- 4 files changed, 118 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef154643f9..0514236926 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3269,7 +3269,7 @@ dependencies = [ [[package]] name = "fontique" version = "0.11.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "parlance" version = "0.1.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" dependencies = [ "bytemuck", ] @@ -6007,7 +6007,7 @@ dependencies = [ [[package]] name = "parley" version = "0.11.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" dependencies = [ "fontique", "hashbrown 0.17.1", @@ -6020,7 +6020,7 @@ dependencies = [ [[package]] name = "parley_data" version = "0.11.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" dependencies = [ "icu_collections", "icu_properties", @@ -6029,12 +6029,12 @@ dependencies = [ [[package]] name = "parley_emoji" version = "0.11.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" [[package]] name = "parley_engine" version = "0.11.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" dependencies = [ "fontique", "harfrust", diff --git a/Cargo.toml b/Cargo.toml index ebbc185f36..4d29804ccb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,7 +108,7 @@ taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "16d3443e4c0de68ef1 "calc", "detailed_layout_info", ] } -parley = { git = "https://github.com/linebender/parley", rev = "e41dfea5cf50669ac67c5a8aca8d1664ffb72904", default-features = false, features = ["std"] } +parley = { git = "https://github.com/DioxusLabs/parley", rev = "0c702661efdd51ad1c8d029dd1313f2c15b1e693", default-features = false, features = ["std"] } skrifa = { version = "0.44", default-features = false, features = [ "std", ] } # Should match parley and vello versions diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index 79b4faeb05..07516c309b 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -1070,7 +1070,6 @@ pub(crate) fn build_inline_layout_into( .as_deref() .map(|s| CaseTransform::from_style(s)) .unwrap_or(CaseTransform::NONE); - let mut text_transformer = TextTransformer::default(); // Render position-inside list items if let Some(ListItemLayout { @@ -1095,6 +1094,9 @@ pub(crate) fn build_inline_layout_into( Marker::String(str) => builder.push_text(str), } }; + // The marker is a separate box, so words in the content don't continue from it. + let mut text_transformer = TextTransformer::default(); + text_transformer.word_break(&mut builder); if let Some(before_id) = root_node.before() { build_inline_layout_recursive( @@ -1204,7 +1206,7 @@ pub(crate) fn build_inline_layout_into( if is_inline_box_element(tag_name) { if box_kind == InlineBoxKind::InFlow { - text_transformer.word_break(); + text_transformer.word_break(builder); } builder.push_inline_box(InlineBox { id: node_id.as_u64(), @@ -1230,7 +1232,7 @@ pub(crate) fn build_inline_layout_into( ]); builder.push_text("\n"); builder.pop_style_span(); - text_transformer.word_break(); + text_transformer.word_break(builder); } else { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); let style = node @@ -1278,7 +1280,7 @@ pub(crate) fn build_inline_layout_into( // Inline box (_, _) => { if box_kind == InlineBoxKind::InFlow { - text_transformer.word_break(); + text_transformer.word_break(builder); } builder.push_inline_box(InlineBox { id: node_id.as_u64(), @@ -1301,7 +1303,8 @@ pub(crate) fn build_inline_layout_into( // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); // dbg!(&data.content); - let text = text_transformer.transform(&data.content, parent_text_transform); + let text = + text_transformer.transform(&data.content, parent_text_transform, builder); builder.push_text(&text); } NodeData::Comment { .. } => { diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index 72ab98a2df..d2bf9cc09c 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -10,6 +10,7 @@ use icu_locale_core::LanguageIdentifier; use icu_properties::props::{GeneralCategory, GeneralCategoryGroup}; use icu_properties::{CodePointMapData, CodePointMapDataBorrowed}; use icu_segmenter::{WordSegmenter, WordSegmenterBorrowed, options::WordBreakInvariantOptions}; +use parley::{Brush, TreeBuilder}; use style::properties::ComputedValues; use style::values::computed::TextTransform; @@ -49,91 +50,88 @@ impl CaseTransform { /// Applies case transforms to the sequence of text nodes in an inline formatting context. /// -/// `capitalize` operates on words, which may span multiple text nodes, so the (untransformed) -/// text preceding the current text node is kept as context for word segmentation. Only -/// look-behind is needed: whether a word boundary precedes a letter never depends on -/// the text that follows it. +/// `capitalize` operates on words, which may span multiple text nodes, so the text already +/// pushed to the [`TreeBuilder`] is used as context for word segmentation. Only look-behind +/// is needed: whether a word boundary precedes a letter never depends on the text that +/// follows it. #[derive(Default)] pub(crate) struct TextTransformer { - context: String, + /// The offset in the builder's text of the last forced word boundary. + context_start: usize, } impl TextTransformer { /// Forces a word boundary (e.g. at an atomic inline or a forced line break). - pub(crate) fn word_break(&mut self) { - self.context.clear(); + pub(crate) fn word_break(&mut self, builder: &mut TreeBuilder<'_, B>) { + self.context_start = builder.text_so_far().text.len(); } - /// Transforms the content of a text node. Must be called for every text node in the - /// inline formatting context (in order), including those that are not transformed. - pub(crate) fn transform<'a>( - &mut self, + /// Transforms the content of a text node that is about to be pushed to `builder`. + pub(crate) fn transform<'a, B: Brush>( + &self, text: &'a str, transform: &CaseTransform, + builder: &mut TreeBuilder<'_, B>, ) -> Cow<'a, str> { - let transformed = match transform.kind { + match transform.kind { TextTransform::UPPERCASE => CASE_MAPPER.uppercase_to_string(text, &transform.lang), TextTransform::LOWERCASE => CASE_MAPPER.lowercase_to_string(text, &transform.lang), - TextTransform::CAPITALIZE => Cow::Owned(self.capitalize(text, &transform.lang)), - _ => Cow::Borrowed(text), - }; - self.push_context(text); - transformed - } - - /// Titlecases the first typographic letter unit of each word, leaving other characters unchanged. - fn capitalize(&self, text: &str, lang: &LanguageIdentifier) -> String { - let mut options = TitlecaseOptions::default(); - options.leading_adjustment = Some(LeadingAdjustment::None); - options.trailing_case = Some(TrailingCase::Unchanged); - - let context_len = self.context.len(); - let combined = [self.context.as_str(), text].concat(); - - let mut output = String::with_capacity(text.len()); - let mut segment_start = 0; - for segment_end in WORD_SEGMENTER.segment_str(&combined).skip(1) { - if segment_end > context_len { - let part_start = segment_start.max(context_len); - let part = &combined[part_start..segment_end]; - - // A word that started in a previous text node has already had its first - // letter unit (if any) transformed (or not) as part of that text node. - let first_letter_already_seen = combined[segment_start..part_start] - .chars() - .any(is_typographic_letter_unit); - - match part.find(is_typographic_letter_unit) { - Some(letter_start) if !first_letter_already_seen => { - output.push_str(&part[..letter_start]); - output.push_str(&TITLECASE_MAPPER.titlecase_segment_to_string( - &part[letter_start..], - lang, - options, - )); - } - _ => output.push_str(part), + TextTransform::CAPITALIZE => { + let text_so_far = builder.text_so_far(); + let preceding = &text_so_far.text[self.context_start..]; + let start = + ceil_char_boundary(preceding, preceding.len().saturating_sub(MAX_CONTEXT_LEN)); + let mut context = Cow::Borrowed(&preceding[start..]); + if text_so_far.pending_whitespace { + context.to_mut().push(' '); } + Cow::Owned(capitalize(&context, text, &transform.lang)) } - segment_start = segment_end; + _ => Cow::Borrowed(text), } - - output } +} + +/// Titlecases the first typographic letter unit of each word in `text`, leaving other +/// characters unchanged. `context` is the text preceding `text` in the same word segmentation +/// context. +fn capitalize(context: &str, text: &str, lang: &LanguageIdentifier) -> String { + let mut options = TitlecaseOptions::default(); + options.leading_adjustment = Some(LeadingAdjustment::None); + options.trailing_case = Some(TrailingCase::Unchanged); + + let context_len = context.len(); + let combined = [context, text].concat(); + + let mut output = String::with_capacity(text.len()); + let mut segment_start = 0; + for segment_end in WORD_SEGMENTER.segment_str(&combined).skip(1) { + if segment_end > context_len { + let part_start = segment_start.max(context_len); + let part = &combined[part_start..segment_end]; + + // A word that started in a previous text node has already had its first + // letter unit (if any) transformed (or not) as part of that text node. + let first_letter_already_seen = combined[segment_start..part_start] + .chars() + .any(is_typographic_letter_unit); - fn push_context(&mut self, text: &str) { - if text.len() >= MAX_CONTEXT_LEN { - self.context.clear(); - let start = ceil_char_boundary(text, text.len() - MAX_CONTEXT_LEN); - self.context.push_str(&text[start..]); - } else { - self.context.push_str(text); - if self.context.len() > MAX_CONTEXT_LEN { - let start = ceil_char_boundary(&self.context, self.context.len() - MAX_CONTEXT_LEN); - self.context.drain(..start); + match part.find(is_typographic_letter_unit) { + Some(letter_start) if !first_letter_already_seen => { + output.push_str(&part[..letter_start]); + output.push_str(&TITLECASE_MAPPER.titlecase_segment_to_string( + &part[letter_start..], + lang, + options, + )); + } + _ => output.push_str(part), } } + segment_start = segment_end; } + + output } /// @@ -153,17 +151,37 @@ fn ceil_char_boundary(s: &str, mut index: usize) -> usize { #[cfg(test)] mod tests { use super::*; + use parley::{FontContext, LayoutContext, TextStyle}; fn transform(kind: TextTransform, lang: &str, texts: &[&str]) -> Vec { let transform = CaseTransform { kind, lang: LanguageIdentifier::try_from_str(lang).unwrap(), }; - let mut transformer = TextTransformer::default(); - texts - .iter() - .map(|text| transformer.transform(text, &transform).into_owned()) - .collect() + with_builder(|builder, transformer| { + texts + .iter() + .map(|text| push(builder, transformer, text, &transform)) + .collect() + }) + } + + fn with_builder(f: impl FnOnce(&mut TreeBuilder<'_, ()>, &mut TextTransformer) -> R) -> R { + let mut font_ctx = FontContext::new(); + let mut layout_ctx = LayoutContext::new(); + let mut builder = layout_ctx.tree_builder(&mut font_ctx, 1.0, true, &TextStyle::default()); + f(&mut builder, &mut TextTransformer::default()) + } + + fn push( + builder: &mut TreeBuilder<'_, ()>, + transformer: &TextTransformer, + text: &str, + transform: &CaseTransform, + ) -> String { + let output = transformer.transform(text, transform, builder).into_owned(); + builder.push_text(&output); + output } fn capitalize(texts: &[&str]) -> Vec { @@ -215,26 +233,34 @@ mod tests { #[test] fn capitalize_mid_word_text_node() { - let mut transformer = TextTransformer::default(); let capitalize = CaseTransform { kind: TextTransform::CAPITALIZE, lang: LanguageIdentifier::UNKNOWN, }; - assert_eq!(transformer.transform("a", &CaseTransform::NONE), "a"); - assert_eq!(transformer.transform("b", &capitalize), "b"); - assert_eq!(transformer.transform("c", &CaseTransform::NONE), "c"); + with_builder(|builder, transformer| { + assert_eq!(push(builder, transformer, "a", &CaseTransform::NONE), "a"); + assert_eq!(push(builder, transformer, "b", &capitalize), "b"); + assert_eq!(push(builder, transformer, "c", &CaseTransform::NONE), "c"); + }); } #[test] fn capitalize_after_word_break() { - let mut transformer = TextTransformer::default(); let capitalize = CaseTransform { kind: TextTransform::CAPITALIZE, lang: LanguageIdentifier::UNKNOWN, }; - assert_eq!(transformer.transform("abc", &capitalize), "Abc"); - transformer.word_break(); - assert_eq!(transformer.transform("def", &capitalize), "Def"); + with_builder(|builder, transformer| { + assert_eq!(push(builder, transformer, "abc", &capitalize), "Abc"); + transformer.word_break(builder); + assert_eq!(push(builder, transformer, "def", &capitalize), "Def"); + }); + } + + #[test] + fn capitalize_after_collapsed_whitespace() { + assert_eq!(capitalize(&["hello ", "world"]), ["Hello ", "World"]); + assert_eq!(capitalize(&["hello\n", "world"]), ["Hello\n", "World"]); } #[test] From b89e47a634522a23a257a9309b1309abc9bdb4ac Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 26 Sep 2026 12:33:18 +0000 Subject: [PATCH 03/13] Ignore language-specific casing when the script subtag contradicts the language --- .../blitz-dom/src/layout/text_transform.rs | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index d2bf9cc09c..384bd8a213 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -43,11 +43,34 @@ impl CaseTransform { return Self::NONE; } let lang = LanguageIdentifier::try_from_str(&style.get_font()._x_lang.0) + .map(casing_language) .unwrap_or(LanguageIdentifier::UNKNOWN); Self { kind, lang } } } +/// Language-specific case mapping rules don't apply if an explicit script subtag contradicts +/// the language's script (e.g. `tr-Cyrl`). +/// +/// +fn casing_language(lang: LanguageIdentifier) -> LanguageIdentifier { + let Some(script) = lang.script else { + return lang; + }; + // The languages with tailored case mappings in ICU4X. + let language_script = match lang.language.as_str() { + "az" | "lt" | "nl" | "tr" => "Latn", + "el" => "Grek", + "hy" => "Armn", + _ => return lang, + }; + if script.as_str() == language_script { + lang + } else { + LanguageIdentifier::UNKNOWN + } +} + /// Applies case transforms to the sequence of text nodes in an inline formatting context. /// /// `capitalize` operates on words, which may span multiple text nodes, so the text already @@ -156,7 +179,7 @@ mod tests { fn transform(kind: TextTransform, lang: &str, texts: &[&str]) -> Vec { let transform = CaseTransform { kind, - lang: LanguageIdentifier::try_from_str(lang).unwrap(), + lang: casing_language(LanguageIdentifier::try_from_str(lang).unwrap()), }; with_builder(|builder, transformer| { texts @@ -290,5 +313,13 @@ mod tests { ["abc"] ); assert_eq!(transform(TextTransform::LOWERCASE, "tr", &["I"]), ["ı"]); + assert_eq!( + transform(TextTransform::LOWERCASE, "tr-Latn", &["I"]), + ["ı"] + ); + assert_eq!( + transform(TextTransform::LOWERCASE, "tr-Cyrl", &["I"]), + ["i"] + ); } } From 7817f64e4e8916df1d5b4ac26680e797dbdc2a9a Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 26 Sep 2026 12:48:06 +0000 Subject: [PATCH 04/13] Update parley: text_so_far is now a read-only getter --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- packages/blitz-dom/src/layout/construct.rs | 2 +- packages/blitz-dom/src/layout/text_transform.rs | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0514236926..ffaab64d14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3269,7 +3269,7 @@ dependencies = [ [[package]] name = "fontique" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" +source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "parlance" version = "0.1.0" -source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" +source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" dependencies = [ "bytemuck", ] @@ -6007,7 +6007,7 @@ dependencies = [ [[package]] name = "parley" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" +source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" dependencies = [ "fontique", "hashbrown 0.17.1", @@ -6020,7 +6020,7 @@ dependencies = [ [[package]] name = "parley_data" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" +source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" dependencies = [ "icu_collections", "icu_properties", @@ -6029,12 +6029,12 @@ dependencies = [ [[package]] name = "parley_emoji" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" +source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" [[package]] name = "parley_engine" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=0c702661efdd51ad1c8d029dd1313f2c15b1e693#0c702661efdd51ad1c8d029dd1313f2c15b1e693" +source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" dependencies = [ "fontique", "harfrust", diff --git a/Cargo.toml b/Cargo.toml index 4d29804ccb..93765b0991 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,7 +108,7 @@ taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "16d3443e4c0de68ef1 "calc", "detailed_layout_info", ] } -parley = { git = "https://github.com/DioxusLabs/parley", rev = "0c702661efdd51ad1c8d029dd1313f2c15b1e693", default-features = false, features = ["std"] } +parley = { git = "https://github.com/DioxusLabs/parley", rev = "b8f5779ed5f5363bf5b74a8060198780429ef892", default-features = false, features = ["std"] } skrifa = { version = "0.44", default-features = false, features = [ "std", ] } # Should match parley and vello versions diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index 07516c309b..9ce1b9f938 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -1096,7 +1096,7 @@ pub(crate) fn build_inline_layout_into( }; // The marker is a separate box, so words in the content don't continue from it. let mut text_transformer = TextTransformer::default(); - text_transformer.word_break(&mut builder); + text_transformer.word_break(&builder); if let Some(before_id) = root_node.before() { build_inline_layout_recursive( diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index 384bd8a213..d42d77be6e 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -85,7 +85,7 @@ pub(crate) struct TextTransformer { impl TextTransformer { /// Forces a word boundary (e.g. at an atomic inline or a forced line break). - pub(crate) fn word_break(&mut self, builder: &mut TreeBuilder<'_, B>) { + pub(crate) fn word_break(&mut self, builder: &TreeBuilder<'_, B>) { self.context_start = builder.text_so_far().text.len(); } @@ -94,7 +94,7 @@ impl TextTransformer { &self, text: &'a str, transform: &CaseTransform, - builder: &mut TreeBuilder<'_, B>, + builder: &TreeBuilder<'_, B>, ) -> Cow<'a, str> { match transform.kind { TextTransform::UPPERCASE => CASE_MAPPER.uppercase_to_string(text, &transform.lang), From 5076a2e3bcc9cd7ed2e8c0c268841df579f14d54 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 26 Sep 2026 13:13:42 +0000 Subject: [PATCH 05/13] Skip capitalize context after pending whitespace --- .../blitz-dom/src/layout/text_transform.rs | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index d42d77be6e..91239bc0bb 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -101,14 +101,19 @@ impl TextTransformer { TextTransform::LOWERCASE => CASE_MAPPER.lowercase_to_string(text, &transform.lang), TextTransform::CAPITALIZE => { let text_so_far = builder.text_so_far(); - let preceding = &text_so_far.text[self.context_start..]; - let start = - ceil_char_boundary(preceding, preceding.len().saturating_sub(MAX_CONTEXT_LEN)); - let mut context = Cow::Borrowed(&preceding[start..]); - if text_so_far.pending_whitespace { - context.to_mut().push(' '); - } - Cow::Owned(capitalize(&context, text, &transform.lang)) + // Pending (collapsed) whitespace always ends the preceding word, so no + // context is needed. + let context = if text_so_far.pending_whitespace { + "" + } else { + let preceding = &text_so_far.text[self.context_start..]; + let start = ceil_char_boundary( + preceding, + preceding.len().saturating_sub(MAX_CONTEXT_LEN), + ); + &preceding[start..] + }; + Cow::Owned(capitalize(context, text, &transform.lang)) } _ => Cow::Borrowed(text), } @@ -124,7 +129,11 @@ fn capitalize(context: &str, text: &str, lang: &LanguageIdentifier) -> String { options.trailing_case = Some(TrailingCase::Unchanged); let context_len = context.len(); - let combined = [context, text].concat(); + let combined = if context.is_empty() { + Cow::Borrowed(text) + } else { + Cow::Owned([context, text].concat()) + }; let mut output = String::with_capacity(text.len()); let mut segment_start = 0; @@ -284,6 +293,11 @@ mod tests { fn capitalize_after_collapsed_whitespace() { assert_eq!(capitalize(&["hello ", "world"]), ["Hello ", "World"]); assert_eq!(capitalize(&["hello\n", "world"]), ["Hello\n", "World"]); + assert_eq!(capitalize(&["hello ", "(world"]), ["Hello ", "(World"]); + assert_eq!( + capitalize(&["hello ", "\u{301}world"]), + ["Hello ", "\u{301}World"] + ); } #[test] From 1773d63acfa05f64fb3663cf9fa82f172ac93b23 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 26 Sep 2026 13:32:03 +0000 Subject: [PATCH 06/13] Reuse buffers for text-transform output and capitalize context --- Cargo.lock | 1 + Cargo.toml | 1 + packages/blitz-dom/Cargo.toml | 1 + packages/blitz-dom/src/layout/construct.rs | 2 +- .../blitz-dom/src/layout/text_transform.rs | 76 +++++++++++++------ 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ffaab64d14..c0255be7de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -947,6 +947,7 @@ dependencies = [ "url", "usvg", "web-time", + "writeable", "wuff", ] diff --git a/Cargo.toml b/Cargo.toml index 93765b0991..8964feeec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -197,6 +197,7 @@ icu_casemap = { version = "2.3", default-features = false, features = ["compiled icu_locale_core = { version = "2.3", default-features = false } icu_properties = { version = "2.3", default-features = false, features = ["compiled_data"] } icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] } +writeable = { version = "0.6", default-features = false } [profile.profile] inherits = "release" diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index 545aec5f43..e569597797 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -74,6 +74,7 @@ icu_casemap = { workspace = true } icu_locale_core = { workspace = true } icu_properties = { workspace = true } icu_segmenter = { workspace = true } +writeable = { workspace = true } # Other dependencies slotmap = { workspace = true } diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index 9ce1b9f938..efacd0d3b2 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -1305,7 +1305,7 @@ pub(crate) fn build_inline_layout_into( let text = text_transformer.transform(&data.content, parent_text_transform, builder); - builder.push_text(&text); + builder.push_text(text); } NodeData::Comment { .. } => { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index 91239bc0bb..56ac7c7ea0 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -2,8 +2,6 @@ //! //! -use std::borrow::Cow; - use icu_casemap::options::{LeadingAdjustment, TitlecaseOptions, TrailingCase}; use icu_casemap::{CaseMapper, CaseMapperBorrowed, TitlecaseMapper, TitlecaseMapperBorrowed}; use icu_locale_core::LanguageIdentifier; @@ -13,6 +11,7 @@ use icu_segmenter::{WordSegmenter, WordSegmenterBorrowed, options::WordBreakInva use parley::{Brush, TreeBuilder}; use style::properties::ComputedValues; use style::values::computed::TextTransform; +use writeable::Writeable; const CASE_MAPPER: CaseMapperBorrowed<'static> = CaseMapper::new(); const TITLECASE_MAPPER: TitlecaseMapperBorrowed<'static> = TitlecaseMapper::new(); @@ -81,6 +80,10 @@ fn casing_language(lang: LanguageIdentifier) -> LanguageIdentifier { pub(crate) struct TextTransformer { /// The offset in the builder's text of the last forced word boundary. context_start: usize, + /// Reused buffer for the context followed by the text to be capitalized. + segmentation_buffer: String, + /// Reused buffer for the transformed text. + output: String, } impl TextTransformer { @@ -91,14 +94,20 @@ impl TextTransformer { /// Transforms the content of a text node that is about to be pushed to `builder`. pub(crate) fn transform<'a, B: Brush>( - &self, + &'a mut self, text: &'a str, transform: &CaseTransform, builder: &TreeBuilder<'_, B>, - ) -> Cow<'a, str> { + ) -> &'a str { + let output = &mut self.output; + output.clear(); match transform.kind { - TextTransform::UPPERCASE => CASE_MAPPER.uppercase_to_string(text, &transform.lang), - TextTransform::LOWERCASE => CASE_MAPPER.lowercase_to_string(text, &transform.lang), + TextTransform::UPPERCASE => { + write(output, CASE_MAPPER.uppercase(text, &transform.lang)); + } + TextTransform::LOWERCASE => { + write(output, CASE_MAPPER.lowercase(text, &transform.lang)); + } TextTransform::CAPITALIZE => { let text_so_far = builder.text_so_far(); // Pending (collapsed) whitespace always ends the preceding word, so no @@ -113,31 +122,51 @@ impl TextTransformer { ); &preceding[start..] }; - Cow::Owned(capitalize(context, text, &transform.lang)) + capitalize( + context, + text, + &transform.lang, + &mut self.segmentation_buffer, + output, + ); } - _ => Cow::Borrowed(text), + _ => return text, } + output } } -/// Titlecases the first typographic letter unit of each word in `text`, leaving other -/// characters unchanged. `context` is the text preceding `text` in the same word segmentation -/// context. -fn capitalize(context: &str, text: &str, lang: &LanguageIdentifier) -> String { +fn write(output: &mut String, writeable: impl Writeable) { + // Writing to a `String` is infallible. + let _ = writeable.write_to(output); +} + +/// Writes `text` to `output`, titlecasing the first typographic letter unit of each word and +/// leaving other characters unchanged. `context` is the text preceding `text` in the same word +/// segmentation context. `buffer` is scratch space. +fn capitalize( + context: &str, + text: &str, + lang: &LanguageIdentifier, + buffer: &mut String, + output: &mut String, +) { let mut options = TitlecaseOptions::default(); options.leading_adjustment = Some(LeadingAdjustment::None); options.trailing_case = Some(TrailingCase::Unchanged); let context_len = context.len(); let combined = if context.is_empty() { - Cow::Borrowed(text) + text } else { - Cow::Owned([context, text].concat()) + buffer.clear(); + buffer.push_str(context); + buffer.push_str(text); + buffer }; - let mut output = String::with_capacity(text.len()); let mut segment_start = 0; - for segment_end in WORD_SEGMENTER.segment_str(&combined).skip(1) { + for segment_end in WORD_SEGMENTER.segment_str(combined).skip(1) { if segment_end > context_len { let part_start = segment_start.max(context_len); let part = &combined[part_start..segment_end]; @@ -151,19 +180,16 @@ fn capitalize(context: &str, text: &str, lang: &LanguageIdentifier) -> String { match part.find(is_typographic_letter_unit) { Some(letter_start) if !first_letter_already_seen => { output.push_str(&part[..letter_start]); - output.push_str(&TITLECASE_MAPPER.titlecase_segment_to_string( - &part[letter_start..], - lang, - options, - )); + write( + output, + TITLECASE_MAPPER.titlecase_segment(&part[letter_start..], lang, options), + ); } _ => output.push_str(part), } } segment_start = segment_end; } - - output } /// @@ -207,11 +233,11 @@ mod tests { fn push( builder: &mut TreeBuilder<'_, ()>, - transformer: &TextTransformer, + transformer: &mut TextTransformer, text: &str, transform: &CaseTransform, ) -> String { - let output = transformer.transform(text, transform, builder).into_owned(); + let output = transformer.transform(text, transform, builder).to_owned(); builder.push_text(&output); output } From 519f3dbb2ff1bda94c785bcfdea14554c463a8ad Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 26 Sep 2026 13:37:16 +0000 Subject: [PATCH 07/13] Borrow unchanged text instead of copying it into the output buffer --- .../blitz-dom/src/layout/text_transform.rs | 105 +++++++++++++++--- 1 file changed, 87 insertions(+), 18 deletions(-) diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index 56ac7c7ea0..2c554a4655 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -2,6 +2,8 @@ //! //! +use std::fmt; + use icu_casemap::options::{LeadingAdjustment, TitlecaseOptions, TrailingCase}; use icu_casemap::{CaseMapper, CaseMapperBorrowed, TitlecaseMapper, TitlecaseMapperBorrowed}; use icu_locale_core::LanguageIdentifier; @@ -99,15 +101,10 @@ impl TextTransformer { transform: &CaseTransform, builder: &TreeBuilder<'_, B>, ) -> &'a str { - let output = &mut self.output; - output.clear(); + let mut output = OutputSink::new(text, &mut self.output); match transform.kind { - TextTransform::UPPERCASE => { - write(output, CASE_MAPPER.uppercase(text, &transform.lang)); - } - TextTransform::LOWERCASE => { - write(output, CASE_MAPPER.lowercase(text, &transform.lang)); - } + TextTransform::UPPERCASE => output.write(CASE_MAPPER.uppercase(text, &transform.lang)), + TextTransform::LOWERCASE => output.write(CASE_MAPPER.lowercase(text, &transform.lang)), TextTransform::CAPITALIZE => { let text_so_far = builder.text_so_far(); // Pending (collapsed) whitespace always ends the preceding word, so no @@ -127,18 +124,65 @@ impl TextTransformer { text, &transform.lang, &mut self.segmentation_buffer, - output, + &mut output, ); } _ => return text, } - output + output.finish() + } +} + +/// Collects transformed text, borrowing the original text if it is unchanged and otherwise +/// copying it into a reused buffer from the first difference onwards. +struct OutputSink<'a> { + original: &'a str, + /// The length of the output so far if it is a prefix of `original`, or `None` if the output + /// has diverged and is in `buffer`. + unchanged_len: Option, + buffer: &'a mut String, +} + +impl<'a> OutputSink<'a> { + fn new(original: &'a str, buffer: &'a mut String) -> Self { + Self { + original, + unchanged_len: Some(0), + buffer, + } + } + + fn push_str(&mut self, s: &str) { + if let Some(len) = self.unchanged_len { + if self.original[len..].starts_with(s) { + self.unchanged_len = Some(len + s.len()); + return; + } + self.buffer.clear(); + self.buffer.push_str(&self.original[..len]); + self.unchanged_len = None; + } + self.buffer.push_str(s); + } + + fn write(&mut self, writeable: impl Writeable) { + // Writing to an `OutputSink` is infallible. + let _ = writeable.write_to(self); + } + + fn finish(self) -> &'a str { + match self.unchanged_len { + Some(len) => &self.original[..len], + None => self.buffer, + } } } -fn write(output: &mut String, writeable: impl Writeable) { - // Writing to a `String` is infallible. - let _ = writeable.write_to(output); +impl fmt::Write for OutputSink<'_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.push_str(s); + Ok(()) + } } /// Writes `text` to `output`, titlecasing the first typographic letter unit of each word and @@ -149,7 +193,7 @@ fn capitalize( text: &str, lang: &LanguageIdentifier, buffer: &mut String, - output: &mut String, + output: &mut OutputSink<'_>, ) { let mut options = TitlecaseOptions::default(); options.leading_adjustment = Some(LeadingAdjustment::None); @@ -180,10 +224,11 @@ fn capitalize( match part.find(is_typographic_letter_unit) { Some(letter_start) if !first_letter_already_seen => { output.push_str(&part[..letter_start]); - write( - output, - TITLECASE_MAPPER.titlecase_segment(&part[letter_start..], lang, options), - ); + output.write(TITLECASE_MAPPER.titlecase_segment( + &part[letter_start..], + lang, + options, + )); } _ => output.push_str(part), } @@ -341,6 +386,30 @@ mod tests { ); } + #[test] + fn unchanged_text_is_borrowed() { + let cases = [ + (TextTransform::UPPERCASE, "ABC 123", true), + (TextTransform::UPPERCASE, "ABc", false), + (TextTransform::LOWERCASE, "abc 123", true), + (TextTransform::LOWERCASE, "abC", false), + (TextTransform::CAPITALIZE, "Hello World", true), + (TextTransform::CAPITALIZE, "Hello world", false), + ]; + with_builder(|builder, transformer| { + for (kind, text, borrowed) in cases { + let transform = CaseTransform { + kind, + lang: LanguageIdentifier::UNKNOWN, + }; + transformer.word_break(builder); + let output = transformer.transform(text, &transform, builder); + assert_eq!(output.as_ptr() == text.as_ptr(), borrowed, "{text:?}"); + builder.push_text(" "); + } + }); + } + #[test] fn uppercase_and_lowercase() { assert_eq!( From fcdde6a2f533791f94758d441d907b7bb774ea40 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 26 Sep 2026 14:18:46 +0000 Subject: [PATCH 08/13] Use ASCII case mapping for ASCII text in uppercase/lowercase transforms --- .../blitz-dom/src/layout/text_transform.rs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index 2c554a4655..0083cf82a5 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -48,6 +48,12 @@ impl CaseTransform { .unwrap_or(LanguageIdentifier::UNKNOWN); Self { kind, lang } } + + /// Whether the language has case mappings for ASCII characters that differ from the + /// default ones (`i` ↔ `İ` and `ı` ↔ `I`). + fn has_turkic_casing(&self) -> bool { + matches!(self.lang.language.as_str(), "tr" | "az") + } } /// Language-specific case mapping rules don't apply if an explicit script subtag contradicts @@ -101,6 +107,28 @@ impl TextTransformer { transform: &CaseTransform, builder: &TreeBuilder<'_, B>, ) -> &'a str { + if text.is_ascii() && !transform.has_turkic_casing() { + match transform.kind { + TextTransform::UPPERCASE => { + return map_ascii( + text, + &mut self.output, + u8::is_ascii_lowercase, + str::make_ascii_uppercase, + ); + } + TextTransform::LOWERCASE => { + return map_ascii( + text, + &mut self.output, + u8::is_ascii_uppercase, + str::make_ascii_lowercase, + ); + } + _ => {} + } + } + let mut output = OutputSink::new(text, &mut self.output); match transform.kind { TextTransform::UPPERCASE => output.write(CASE_MAPPER.uppercase(text, &transform.lang)), @@ -133,6 +161,23 @@ impl TextTransformer { } } +/// Case maps ASCII `text`, borrowing it if no bytes need mapping and otherwise copying it into +/// `buffer`. +fn map_ascii<'a>( + text: &'a str, + buffer: &'a mut String, + needs_mapping: impl Fn(&u8) -> bool, + map: impl FnOnce(&mut str), +) -> &'a str { + let Some(first) = text.bytes().position(|b| needs_mapping(&b)) else { + return text; + }; + buffer.clear(); + buffer.push_str(text); + map(&mut buffer[first..]); + buffer +} + /// Collects transformed text, borrowing the original text if it is unchanged and otherwise /// copying it into a reused buffer from the first difference onwards. struct OutputSink<'a> { @@ -410,6 +455,24 @@ mod tests { }); } + #[test] + fn ascii_fast_path_matches_icu() { + let ascii: String = (0..128u8).map(char::from).collect(); + for lang in ["und", "en", "lt", "nl", "el", "tr", "az"] { + let locale = LanguageIdentifier::try_from_str(lang).unwrap(); + assert_eq!( + transform(TextTransform::UPPERCASE, lang, &[&ascii]), + [CASE_MAPPER.uppercase_to_string(&ascii, &locale)], + "{lang}" + ); + assert_eq!( + transform(TextTransform::LOWERCASE, lang, &[&ascii]), + [CASE_MAPPER.lowercase_to_string(&ascii, &locale)], + "{lang}" + ); + } + } + #[test] fn uppercase_and_lowercase() { assert_eq!( From 525ea4c322850e2028f10f3f06212a5af195cb12 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 26 Sep 2026 14:24:28 +0000 Subject: [PATCH 09/13] Skip text transform work for text without a case transform --- packages/blitz-dom/src/layout/text_transform.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index 0083cf82a5..c71f647dfb 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -107,6 +107,10 @@ impl TextTransformer { transform: &CaseTransform, builder: &TreeBuilder<'_, B>, ) -> &'a str { + if transform.kind.is_empty() { + return text; + } + if text.is_ascii() && !transform.has_turkic_casing() { match transform.kind { TextTransform::UPPERCASE => { From e784b8c8ceec4056cb42057b02b1d200cba91b32 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Mon, 28 Sep 2026 20:04:44 +0000 Subject: [PATCH 10/13] Use parley's TreeBuilder::text and has_pending_whitespace --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- packages/blitz-dom/src/layout/text_transform.rs | 7 +++---- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0255be7de..27638536c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3270,7 +3270,7 @@ dependencies = [ [[package]] name = "fontique" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" +source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", @@ -6000,7 +6000,7 @@ dependencies = [ [[package]] name = "parlance" version = "0.1.0" -source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" +source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" dependencies = [ "bytemuck", ] @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "parley" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" +source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" dependencies = [ "fontique", "hashbrown 0.17.1", @@ -6021,7 +6021,7 @@ dependencies = [ [[package]] name = "parley_data" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" +source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" dependencies = [ "icu_collections", "icu_properties", @@ -6030,12 +6030,12 @@ dependencies = [ [[package]] name = "parley_emoji" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" +source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" [[package]] name = "parley_engine" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=b8f5779ed5f5363bf5b74a8060198780429ef892#b8f5779ed5f5363bf5b74a8060198780429ef892" +source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" dependencies = [ "fontique", "harfrust", diff --git a/Cargo.toml b/Cargo.toml index 8964feeec8..9515bf82f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,7 +108,7 @@ taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "16d3443e4c0de68ef1 "calc", "detailed_layout_info", ] } -parley = { git = "https://github.com/DioxusLabs/parley", rev = "b8f5779ed5f5363bf5b74a8060198780429ef892", default-features = false, features = ["std"] } +parley = { git = "https://github.com/DioxusLabs/parley", rev = "3c979972152dbe01ec7c905fe4f16f1e9628f0d1", default-features = false, features = ["std"] } skrifa = { version = "0.44", default-features = false, features = [ "std", ] } # Should match parley and vello versions diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index c71f647dfb..bc5499e3da 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -97,7 +97,7 @@ pub(crate) struct TextTransformer { impl TextTransformer { /// Forces a word boundary (e.g. at an atomic inline or a forced line break). pub(crate) fn word_break(&mut self, builder: &TreeBuilder<'_, B>) { - self.context_start = builder.text_so_far().text.len(); + self.context_start = builder.text().len(); } /// Transforms the content of a text node that is about to be pushed to `builder`. @@ -138,13 +138,12 @@ impl TextTransformer { TextTransform::UPPERCASE => output.write(CASE_MAPPER.uppercase(text, &transform.lang)), TextTransform::LOWERCASE => output.write(CASE_MAPPER.lowercase(text, &transform.lang)), TextTransform::CAPITALIZE => { - let text_so_far = builder.text_so_far(); // Pending (collapsed) whitespace always ends the preceding word, so no // context is needed. - let context = if text_so_far.pending_whitespace { + let context = if builder.has_pending_whitespace() { "" } else { - let preceding = &text_so_far.text[self.context_start..]; + let preceding = &builder.text()[self.context_start..]; let start = ceil_char_boundary( preceding, preceding.len().saturating_sub(MAX_CONTEXT_LEN), From 21466845e47409b4f0b4a269091921d6ac5e3810 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 7 Oct 2026 17:59:33 +0000 Subject: [PATCH 11/13] Update parley to latest linebender/parley#856 --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27638536c9..8dd2e25ae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3270,7 +3270,7 @@ dependencies = [ [[package]] name = "fontique" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" +source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", @@ -6000,7 +6000,7 @@ dependencies = [ [[package]] name = "parlance" version = "0.1.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" +source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" dependencies = [ "bytemuck", ] @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "parley" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" +source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" dependencies = [ "fontique", "hashbrown 0.17.1", @@ -6021,7 +6021,7 @@ dependencies = [ [[package]] name = "parley_data" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" +source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" dependencies = [ "icu_collections", "icu_properties", @@ -6030,12 +6030,12 @@ dependencies = [ [[package]] name = "parley_emoji" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" +source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" [[package]] name = "parley_engine" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3c979972152dbe01ec7c905fe4f16f1e9628f0d1#3c979972152dbe01ec7c905fe4f16f1e9628f0d1" +source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" dependencies = [ "fontique", "harfrust", diff --git a/Cargo.toml b/Cargo.toml index 9515bf82f4..976ad9ac01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,7 +108,7 @@ taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "16d3443e4c0de68ef1 "calc", "detailed_layout_info", ] } -parley = { git = "https://github.com/DioxusLabs/parley", rev = "3c979972152dbe01ec7c905fe4f16f1e9628f0d1", default-features = false, features = ["std"] } +parley = { git = "https://github.com/DioxusLabs/parley", rev = "3cc84a9ed2b6a63216ac56a24023a31a9d54e010", default-features = false, features = ["std"] } skrifa = { version = "0.44", default-features = false, features = [ "std", ] } # Should match parley and vello versions From 0d4c2432d4a659c2bec783542e504228ecbed633 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 7 Oct 2026 18:20:49 +0000 Subject: [PATCH 12/13] Update parley to linebender main (includes #856) --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- packages/blitz-paint/src/text.rs | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8dd2e25ae3..20f04b8d36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3270,7 +3270,7 @@ dependencies = [ [[package]] name = "fontique" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", @@ -6000,7 +6000,7 @@ dependencies = [ [[package]] name = "parlance" version = "0.1.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "bytemuck", ] @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "parley" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "fontique", "hashbrown 0.17.1", @@ -6021,7 +6021,7 @@ dependencies = [ [[package]] name = "parley_data" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "icu_collections", "icu_properties", @@ -6030,12 +6030,12 @@ dependencies = [ [[package]] name = "parley_emoji" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" [[package]] name = "parley_engine" version = "0.11.0" -source = "git+https://github.com/DioxusLabs/parley?rev=3cc84a9ed2b6a63216ac56a24023a31a9d54e010#3cc84a9ed2b6a63216ac56a24023a31a9d54e010" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "fontique", "harfrust", diff --git a/Cargo.toml b/Cargo.toml index 976ad9ac01..71785f1b35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,7 +108,7 @@ taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "16d3443e4c0de68ef1 "calc", "detailed_layout_info", ] } -parley = { git = "https://github.com/DioxusLabs/parley", rev = "3cc84a9ed2b6a63216ac56a24023a31a9d54e010", default-features = false, features = ["std"] } +parley = { git = "https://github.com/linebender/parley", rev = "62790943af982ad3388a3ff96cef6f90ac80a8bb", default-features = false, features = ["std"] } skrifa = { version = "0.44", default-features = false, features = [ "std", ] } # Should match parley and vello versions diff --git a/packages/blitz-paint/src/text.rs b/packages/blitz-paint/src/text.rs index c68e6fac9e..3eb7204306 100644 --- a/packages/blitz-paint/src/text.rs +++ b/packages/blitz-paint/src/text.rs @@ -589,7 +589,7 @@ pub(crate) fn stroke_text<'a>( for item in line.items() { if let PositionedLayoutItem::GlyphRun(glyph_run) = item { let run = glyph_run.run(); - let font = &run.font().font; + let font = run.font(); let font_size = run.font_size(); let metrics = run.font_metrics(); let style = glyph_run.style(); From e60c056cc8edea010c13e19461a4fe5eb1c817cd Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Wed, 7 Oct 2026 18:37:55 +0000 Subject: [PATCH 13/13] Put ICU4X case mapping behind an icu-text-transform feature (on by default) --- .github/workflows/ci.yml | 1 + apps/browser/Cargo.toml | 2 +- packages/blitz-dom/Cargo.toml | 10 +- .../blitz-dom/src/layout/text_transform.rs | 99 +++++++++++++++---- packages/blitz/Cargo.toml | 3 +- packages/dioxus-native-dom/Cargo.toml | 3 +- packages/dioxus-native/Cargo.toml | 3 +- wpt/runner/Cargo.toml | 2 +- 8 files changed, 98 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fe81be150..357b9ae459 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,7 @@ jobs: - run: perl -pi.bak -e 's/opt-level = 2/opt-level = 0/g' Cargo.toml - run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev - run: cargo clippy --workspace -- -D warnings + - run: cargo clippy -p blitz-dom --no-default-features --features svg,woff,accessibility,system-fonts,file-input,custom-widget --all-targets -- -D warnings ci-scripts: name: "Test CI scripts" diff --git a/apps/browser/Cargo.toml b/apps/browser/Cargo.toml index 593263974f..91b2fb135f 100644 --- a/apps/browser/Cargo.toml +++ b/apps/browser/Cargo.toml @@ -51,7 +51,7 @@ dioxus-native = { workspace = true, features = [ "hot-reload" ] } blitz-traits = { workspace = true } -blitz-dom = { workspace = true, features = ["woff", "parallel-construct", "floats"] } +blitz-dom = { workspace = true, features = ["woff", "parallel-construct", "floats", "icu-text-transform"] } blitz-net = { workspace = true, features = ["http2"] } blitz-html = { workspace = true } blitz-vibey-script = { workspace = true, optional = true } diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index e569597797..a32bb857ba 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -17,7 +17,8 @@ default = [ "accessibility", "system-fonts", "file-input", - "custom-widget" + "custom-widget", + "icu-text-transform" ] custom-widget = ["dep:anyrender", "accessibility"] tracing = ["dep:tracing"] @@ -28,6 +29,9 @@ accessibility = ["accesskit"] system-fonts = ["parley/system"] # complex-scripts: enable dictionary-based line-breaking complex-scripts = ["parley/complex-scripts"] +# icu-text-transform: language-aware, Unicode-complete case mapping for `text-transform` +# (otherwise falls back to the standard library's case mapping) +icu-text-transform = ["dep:icu_casemap", "dep:writeable"] autofocus = [] floats = ["taffy/float_layout", "stylo_taffy/floats"] file-input = [] @@ -70,11 +74,11 @@ color = { workspace = true } kurbo = { workspace = true } # Unicode -icu_casemap = { workspace = true } +icu_casemap = { workspace = true, optional = true } icu_locale_core = { workspace = true } icu_properties = { workspace = true } icu_segmenter = { workspace = true } -writeable = { workspace = true } +writeable = { workspace = true, optional = true } # Other dependencies slotmap = { workspace = true } diff --git a/packages/blitz-dom/src/layout/text_transform.rs b/packages/blitz-dom/src/layout/text_transform.rs index bc5499e3da..b176b33435 100644 --- a/packages/blitz-dom/src/layout/text_transform.rs +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -2,9 +2,9 @@ //! //! -use std::fmt; - +#[cfg(feature = "icu-text-transform")] use icu_casemap::options::{LeadingAdjustment, TitlecaseOptions, TrailingCase}; +#[cfg(feature = "icu-text-transform")] use icu_casemap::{CaseMapper, CaseMapperBorrowed, TitlecaseMapper, TitlecaseMapperBorrowed}; use icu_locale_core::LanguageIdentifier; use icu_properties::props::{GeneralCategory, GeneralCategoryGroup}; @@ -13,9 +13,12 @@ use icu_segmenter::{WordSegmenter, WordSegmenterBorrowed, options::WordBreakInva use parley::{Brush, TreeBuilder}; use style::properties::ComputedValues; use style::values::computed::TextTransform; +#[cfg(feature = "icu-text-transform")] use writeable::Writeable; +#[cfg(feature = "icu-text-transform")] const CASE_MAPPER: CaseMapperBorrowed<'static> = CaseMapper::new(); +#[cfg(feature = "icu-text-transform")] const TITLECASE_MAPPER: TitlecaseMapperBorrowed<'static> = TitlecaseMapper::new(); const WORD_SEGMENTER: WordSegmenterBorrowed<'static> = WordSegmenter::new_for_non_complex_scripts(WordBreakInvariantOptions::default()); @@ -52,7 +55,7 @@ impl CaseTransform { /// Whether the language has case mappings for ASCII characters that differ from the /// default ones (`i` ↔ `İ` and `ı` ↔ `I`). fn has_turkic_casing(&self) -> bool { - matches!(self.lang.language.as_str(), "tr" | "az") + cfg!(feature = "icu-text-transform") && matches!(self.lang.language.as_str(), "tr" | "az") } } @@ -135,8 +138,8 @@ impl TextTransformer { let mut output = OutputSink::new(text, &mut self.output); match transform.kind { - TextTransform::UPPERCASE => output.write(CASE_MAPPER.uppercase(text, &transform.lang)), - TextTransform::LOWERCASE => output.write(CASE_MAPPER.lowercase(text, &transform.lang)), + TextTransform::UPPERCASE => uppercase(text, &transform.lang, &mut output), + TextTransform::LOWERCASE => lowercase(text, &transform.lang, &mut output), TextTransform::CAPITALIZE => { // Pending (collapsed) whitespace always ends the preceding word, so no // context is needed. @@ -213,6 +216,7 @@ impl<'a> OutputSink<'a> { self.buffer.push_str(s); } + #[cfg(feature = "icu-text-transform")] fn write(&mut self, writeable: impl Writeable) { // Writing to an `OutputSink` is infallible. let _ = writeable.write_to(self); @@ -226,8 +230,9 @@ impl<'a> OutputSink<'a> { } } -impl fmt::Write for OutputSink<'_> { - fn write_str(&mut self, s: &str) -> fmt::Result { +#[cfg(feature = "icu-text-transform")] +impl std::fmt::Write for OutputSink<'_> { + fn write_str(&mut self, s: &str) -> std::fmt::Result { self.push_str(s); Ok(()) } @@ -243,10 +248,6 @@ fn capitalize( buffer: &mut String, output: &mut OutputSink<'_>, ) { - let mut options = TitlecaseOptions::default(); - options.leading_adjustment = Some(LeadingAdjustment::None); - options.trailing_case = Some(TrailingCase::Unchanged); - let context_len = context.len(); let combined = if context.is_empty() { text @@ -272,11 +273,7 @@ fn capitalize( match part.find(is_typographic_letter_unit) { Some(letter_start) if !first_letter_already_seen => { output.push_str(&part[..letter_start]); - output.write(TITLECASE_MAPPER.titlecase_segment( - &part[letter_start..], - lang, - options, - )); + titlecase_segment(&part[letter_start..], lang, output); } _ => output.push_str(part), } @@ -285,6 +282,62 @@ fn capitalize( } } +#[cfg(feature = "icu-text-transform")] +fn uppercase(text: &str, lang: &LanguageIdentifier, output: &mut OutputSink<'_>) { + output.write(CASE_MAPPER.uppercase(text, lang)); +} + +#[cfg(feature = "icu-text-transform")] +fn lowercase(text: &str, lang: &LanguageIdentifier, output: &mut OutputSink<'_>) { + output.write(CASE_MAPPER.lowercase(text, lang)); +} + +/// Titlecases the first character of `text`, which must start with a typographic letter unit, +/// leaving the rest unchanged. +#[cfg(feature = "icu-text-transform")] +fn titlecase_segment(text: &str, lang: &LanguageIdentifier, output: &mut OutputSink<'_>) { + let mut options = TitlecaseOptions::default(); + options.leading_adjustment = Some(LeadingAdjustment::None); + options.trailing_case = Some(TrailingCase::Unchanged); + output.write(TITLECASE_MAPPER.titlecase_segment(text, lang, options)); +} + +#[cfg(not(feature = "icu-text-transform"))] +fn uppercase(text: &str, _lang: &LanguageIdentifier, output: &mut OutputSink<'_>) { + for c in text.chars() { + push_chars(output, c.to_uppercase()); + } +} + +#[cfg(not(feature = "icu-text-transform"))] +fn lowercase(text: &str, _lang: &LanguageIdentifier, output: &mut OutputSink<'_>) { + // Only `str::to_lowercase` implements the context-sensitive final sigma rule. + if text.contains('Σ') { + output.push_str(&text.to_lowercase()); + return; + } + for c in text.chars() { + push_chars(output, c.to_lowercase()); + } +} + +/// Approximates titlecasing with uppercasing, as the standard library has no titlecase mapping. +#[cfg(not(feature = "icu-text-transform"))] +fn titlecase_segment(text: &str, _lang: &LanguageIdentifier, output: &mut OutputSink<'_>) { + let mut chars = text.chars(); + if let Some(first) = chars.next() { + push_chars(output, first.to_uppercase()); + } + output.push_str(chars.as_str()); +} + +#[cfg(not(feature = "icu-text-transform"))] +fn push_chars(output: &mut OutputSink<'_>, chars: impl Iterator) { + for c in chars { + output.push_str(c.encode_utf8(&mut [0; 4])); + } +} + /// fn is_typographic_letter_unit(c: char) -> bool { let category = GENERAL_CATEGORY.get(c); @@ -362,6 +415,7 @@ mod tests { } #[test] + #[cfg(feature = "icu-text-transform")] fn capitalize_uses_titlecase() { assert_eq!(capitalize(&["dždž"]), ["Dždž"]); assert_eq!(capitalize(&["ßa"]), ["Ssa"]); @@ -427,6 +481,7 @@ mod tests { } #[test] + #[cfg(feature = "icu-text-transform")] fn capitalize_language_sensitive() { assert_eq!( transform(TextTransform::CAPITALIZE, "nl", &["ijsland"]), @@ -459,6 +514,7 @@ mod tests { } #[test] + #[cfg(feature = "icu-text-transform")] fn ascii_fast_path_matches_icu() { let ascii: String = (0..128u8).map(char::from).collect(); for lang in ["und", "en", "lt", "nl", "el", "tr", "az"] { @@ -482,11 +538,20 @@ mod tests { transform(TextTransform::UPPERCASE, "und", &["straße"]), ["STRASSE"] ); - assert_eq!(transform(TextTransform::UPPERCASE, "tr", &["i"]), ["İ"]); assert_eq!( transform(TextTransform::LOWERCASE, "und", &["ABC"]), ["abc"] ); + assert_eq!( + transform(TextTransform::LOWERCASE, "und", &["ΟΔΟΣ Σ"]), + ["οδος σ"] + ); + } + + #[test] + #[cfg(feature = "icu-text-transform")] + fn uppercase_and_lowercase_language_sensitive() { + assert_eq!(transform(TextTransform::UPPERCASE, "tr", &["i"]), ["İ"]); assert_eq!(transform(TextTransform::LOWERCASE, "tr", &["I"]), ["ı"]); assert_eq!( transform(TextTransform::LOWERCASE, "tr-Latn", &["I"]), diff --git a/packages/blitz/Cargo.toml b/packages/blitz/Cargo.toml index 47f1e3a9c9..0e725418f0 100644 --- a/packages/blitz/Cargo.toml +++ b/packages/blitz/Cargo.toml @@ -11,9 +11,10 @@ edition.workspace = true rust-version.workspace = true [features] -default = ["net", "accessibility", "tracing"] +default = ["net", "accessibility", "icu-text-transform", "tracing"] net = ["dep:tokio", "dep:url", "dep:blitz-net"] accessibility = ["blitz-shell/accessibility"] +icu-text-transform = ["blitz-dom/icu-text-transform"] tracing = ["dep:tracing", "blitz-shell/tracing", "blitz-html/tracing", "blitz-net/tracing"] scrollbars = ["blitz-paint/scrollbars"] diff --git a/packages/dioxus-native-dom/Cargo.toml b/packages/dioxus-native-dom/Cargo.toml index 5832f3e0bd..c4ff40b2fb 100644 --- a/packages/dioxus-native-dom/Cargo.toml +++ b/packages/dioxus-native-dom/Cargo.toml @@ -10,7 +10,7 @@ homepage = "https://dioxuslabs.com/learn/0.7/getting_started" keywords = ["dom", "ui", "gui", "react"] [features] -default = ["accessibility", "svg", "system-fonts"] +default = ["accessibility", "svg", "system-fonts", "icu-text-transform"] # DOM features svg = ["blitz-dom/svg"] @@ -21,6 +21,7 @@ system-fonts = ["blitz-dom/system-fonts"] # complex-scripts: enable dictionary-based line-breaking complex-scripts = ["blitz-dom/complex-scripts"] autofocus = ["blitz-dom/autofocus"] +icu-text-transform = ["blitz-dom/icu-text-transform"] [dependencies] # Blitz dependencies diff --git a/packages/dioxus-native/Cargo.toml b/packages/dioxus-native/Cargo.toml index ff4367c4e7..8bb202b69f 100644 --- a/packages/dioxus-native/Cargo.toml +++ b/packages/dioxus-native/Cargo.toml @@ -10,7 +10,7 @@ homepage = "https://dioxuslabs.com/learn/0.7/getting_started" keywords = ["dom", "ui", "gui", "react"] [features] -default = ["accessibility", "hot-reload", "net", "html", "svg", "system-fonts", "clipboard", "file-dialog", "vello-hybrid", "woff", "apple-font-embolden"] +default = ["accessibility", "hot-reload", "net", "html", "svg", "system-fonts", "clipboard", "file-dialog", "vello-hybrid", "woff", "icu-text-transform", "apple-font-embolden"] prelude = ["dep:dioxus-core-macro", "dep:dioxus-hooks", "dep:dioxus-signals", "dep:dioxus-stores", "dep:manganis"] # DOM features @@ -23,6 +23,7 @@ complex-scripts = ["blitz-dom/complex-scripts"] # WOFF/WOFF2 decoder. Default-on so wasm callers (which can't use system fonts) # can bundle compressed fonts without extra setup. woff = ["blitz-dom/woff"] +icu-text-transform = ["blitz-dom/icu-text-transform"] # Shell Features clipboard = ["blitz-shell/clipboard"] diff --git a/wpt/runner/Cargo.toml b/wpt/runner/Cargo.toml index bfbac00c92..e5b0d57526 100644 --- a/wpt/runner/Cargo.toml +++ b/wpt/runner/Cargo.toml @@ -12,7 +12,7 @@ gpu = ["dep:anyrender_vello"] cpu = ["dep:anyrender_vello_cpu"] [dependencies] -blitz-dom = { workspace = true, features = ["svg", "floats", "system-fonts", "woff", "complex-scripts"] } +blitz-dom = { workspace = true, features = ["svg", "floats", "system-fonts", "woff", "complex-scripts", "icu-text-transform"] } blitz-html = {workspace = true } blitz-vibey-script = { workspace = true } blitz-traits = { workspace = true }