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/Cargo.lock b/Cargo.lock index 8a742ac634..20f04b8d36 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", @@ -943,6 +947,7 @@ dependencies = [ "url", "usvg", "web-time", + "writeable", "wuff", ] @@ -3265,7 +3270,7 @@ dependencies = [ [[package]] name = "fontique" version = "0.11.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", @@ -4086,6 +4091,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 +4121,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -5972,7 +6000,7 @@ dependencies = [ [[package]] name = "parlance" version = "0.1.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "bytemuck", ] @@ -5980,7 +6008,7 @@ dependencies = [ [[package]] name = "parley" version = "0.11.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "fontique", "hashbrown 0.17.1", @@ -5993,7 +6021,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/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "icu_collections", "icu_properties", @@ -6002,12 +6030,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/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" [[package]] name = "parley_engine" version = "0.11.0" -source = "git+https://github.com/linebender/parley?rev=e41dfea5cf50669ac67c5a8aca8d1664ffb72904#e41dfea5cf50669ac67c5a8aca8d1664ffb72904" +source = "git+https://github.com/linebender/parley?rev=62790943af982ad3388a3ff96cef6f90ac80a8bb#62790943af982ad3388a3ff96cef6f90ac80a8bb" dependencies = [ "fontique", "harfrust", diff --git a/Cargo.toml b/Cargo.toml index a5726ca19b..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/linebender/parley", rev = "e41dfea5cf50669ac67c5a8aca8d1664ffb72904", 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 @@ -192,6 +192,13 @@ 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"] } +writeable = { version = "0.6", default-features = false } + [profile.profile] inherits = "release" debug = true 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 ab86c3b719..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 = [] @@ -69,6 +73,13 @@ linebender_resource_handle = { workspace = true } color = { workspace = true } kurbo = { workspace = true } +# Unicode +icu_casemap = { workspace = true, optional = true } +icu_locale_core = { workspace = true } +icu_properties = { workspace = true } +icu_segmenter = { workspace = true } +writeable = { workspace = true, optional = 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..efacd0d3b2 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,9 @@ 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); // Render position-inside list items if let Some(ListItemLayout { @@ -1093,15 +1094,36 @@ 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(&builder); 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 +1131,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 +1142,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 +1191,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 +1205,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); + } builder.push_inline_box(InlineBox { id: node_id.as_u64(), kind: box_kind, @@ -1200,6 +1232,7 @@ pub(crate) fn build_inline_layout_into( ]); builder.push_text("\n"); builder.pop_style_span(); + text_transformer.word_break(builder); } else { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); let style = node @@ -1215,26 +1248,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 +1279,9 @@ pub(crate) fn build_inline_layout_into( } // Inline box (_, _) => { + if box_kind == InlineBoxKind::InFlow { + text_transformer.word_break(builder); + } builder.push_inline_box(InlineBox { id: node_id.as_u64(), kind: box_kind, @@ -1264,18 +1303,9 @@ 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); + 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..b176b33435 --- /dev/null +++ b/packages/blitz-dom/src/layout/text_transform.rs @@ -0,0 +1,565 @@ +//! Case mapping for the CSS `text-transform` property. +//! +//! + +#[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}; +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; +#[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()); +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) + .map(casing_language) + .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 { + cfg!(feature = "icu-text-transform") && matches!(self.lang.language.as_str(), "tr" | "az") + } +} + +/// 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 +/// 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 { + /// 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 { + /// 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().len(); + } + + /// Transforms the content of a text node that is about to be pushed to `builder`. + pub(crate) fn transform<'a, B: Brush>( + &'a mut self, + text: &'a str, + 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 => { + 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 => 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. + let context = if builder.has_pending_whitespace() { + "" + } else { + let preceding = &builder.text()[self.context_start..]; + let start = ceil_char_boundary( + preceding, + preceding.len().saturating_sub(MAX_CONTEXT_LEN), + ); + &preceding[start..] + }; + capitalize( + context, + text, + &transform.lang, + &mut self.segmentation_buffer, + &mut output, + ); + } + _ => return text, + } + output.finish() + } +} + +/// 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> { + 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); + } + + #[cfg(feature = "icu-text-transform")] + 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, + } + } +} + +#[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(()) + } +} + +/// 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 OutputSink<'_>, +) { + let context_len = context.len(); + let combined = if context.is_empty() { + text + } else { + buffer.clear(); + buffer.push_str(context); + buffer.push_str(text); + buffer + }; + + 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]); + titlecase_segment(&part[letter_start..], lang, output); + } + _ => output.push_str(part), + } + } + segment_start = segment_end; + } +} + +#[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); + 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::*; + use parley::{FontContext, LayoutContext, TextStyle}; + + fn transform(kind: TextTransform, lang: &str, texts: &[&str]) -> Vec { + let transform = CaseTransform { + kind, + lang: casing_language(LanguageIdentifier::try_from_str(lang).unwrap()), + }; + 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: &mut TextTransformer, + text: &str, + transform: &CaseTransform, + ) -> String { + let output = transformer.transform(text, transform, builder).to_owned(); + builder.push_text(&output); + output + } + + 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(&["({[-–\"«'.( 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(); 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 }