diff --git a/Cargo.lock b/Cargo.lock index 1600bc64f..2e124643d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2898,7 +2898,6 @@ dependencies = [ "fontique", "harfrust", "hashbrown 0.17.1", - "icu_normalizer", "icu_properties", "linebender_resource_handle", "oxipng", @@ -2922,6 +2921,9 @@ dependencies = [ name = "parley_core" version = "0.11.0" dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", "icu_normalizer", "icu_properties", "icu_segmenter", diff --git a/parley/Cargo.toml b/parley/Cargo.toml index a2d77d417..7add31b7f 100644 --- a/parley/Cargo.toml +++ b/parley/Cargo.toml @@ -44,13 +44,13 @@ core_maths = { version = "0.1.1", optional = true } accesskit = { workspace = true, optional = true } hashbrown = { workspace = true } harfrust = { workspace = true } -icu_normalizer = { workspace = true, features = ["compiled_data"] } -icu_properties = { workspace = true, features = ["compiled_data"] } [dev-dependencies] parley_dev = { workspace = true } peniko = { workspace = true } +icu_properties = { workspace = true, features = ["compiled_data"] } + # We special-case android targets because oxipng doesn't build in Android CI. [target.'cfg(not(target_os = "android"))'.dev-dependencies] oxipng = { version = "9.1.5", default-features = false, features = ["freestanding"] } diff --git a/parley/src/analysis.rs b/parley/src/analysis.rs index 0faf32841..b9be0e506 100644 --- a/parley/src/analysis.rs +++ b/parley/src/analysis.rs @@ -1,14 +1,11 @@ // Copyright 2026 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -pub(crate) mod cluster; - use crate::{Brush, LayoutContext}; -use icu_properties::props::{GeneralCategory, Script}; use parley_core::break_overrides::LineBreakOverrideFn; -use parley_core::{AnalysisDataSources, AnalysisOptions}; +use parley_core::AnalysisOptions; use parlance::WordBreak; @@ -34,22 +31,3 @@ pub(crate) fn analyze_text( }; lcx.analyzer.analyze(text, &options, &mut lcx.analysis); } - -/// All characters contribute to shaping except: -/// - Control characters -/// - Format characters, unless they use the "Inherited" script -// TODO: this function is duplicated at the time of writing, as it also exists in `parley_core`. -// Once more of `parley` is moved to `parley_core`, this function should be removable. -#[inline(always)] -pub(crate) fn contributes_to_shaping(general_category: GeneralCategory, script: Script) -> bool { - if matches!( - general_category, - GeneralCategory::Control - | GeneralCategory::LineSeparator - | GeneralCategory::ParagraphSeparator - ) { - return false; - } - - !(general_category == GeneralCategory::Format && script != Script::Inherited) -} diff --git a/parley/src/context.rs b/parley/src/context.rs index 96e53fd78..7358006d5 100644 --- a/parley/src/context.rs +++ b/parley/src/context.rs @@ -8,7 +8,7 @@ use core::ops::Range; use alloc::{vec, vec::Vec}; use parlance::WordBreak; -use parley_core::{Analysis, AnalysisDataSources, Analyzer}; +use parley_core::{Analysis, AnalysisDataSources, Analyzer, Shaper}; use super::FontContext; use super::builder::{RangedBuilder, StyleRunBuilder}; @@ -18,7 +18,6 @@ use super::style::{Brush, TextStyle}; use crate::builder::TreeBuilder; use crate::inline_box::InlineBox; -use crate::shape::ShapeContext; /// Shared scratch space used when constructing text layouts. /// @@ -40,7 +39,7 @@ pub struct LayoutContext { /// Style index for each character, parallel to [`Analysis::char_info`]. pub(crate) char_style_indices: Vec, - pub(crate) scx: ShapeContext, + pub(crate) scx: Shaper, // Unicode analysis data sources (provided by icu) pub(crate) analysis_data_sources: AnalysisDataSources, @@ -60,7 +59,7 @@ impl LayoutContext { tree_style_builder: TreeStyleBuilder::default(), char_style_indices: vec![], analysis_data_sources: AnalysisDataSources::new(), - scx: ShapeContext::default(), + scx: Shaper::default(), } } diff --git a/parley/src/convert.rs b/parley/src/convert.rs deleted file mode 100644 index ee13a3129..000000000 --- a/parley/src/convert.rs +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2024 the Parley Authors -// SPDX-License-Identifier: Apache-2.0 OR MIT - -pub(crate) fn script_to_harfrust(script: fontique::Script) -> harfrust::Script { - harfrust::Script::from_iso15924_tag(harfrust::Tag::new(&script.to_bytes())) - .unwrap_or(harfrust::script::UNKNOWN) -} diff --git a/parley/src/editing/cursor.rs b/parley/src/editing/cursor.rs index ff7a721e8..52fd12e04 100644 --- a/parley/src/editing/cursor.rs +++ b/parley/src/editing/cursor.rs @@ -2,14 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use crate::BoundingBox; -#[cfg(feature = "accesskit")] -use crate::analysis::cluster::Whitespace; use crate::layout::{Affinity, BreakReason, Cluster, ClusterSide, Layout, Line}; #[cfg(feature = "accesskit")] use crate::layout::{ClusterPath, LayoutAccessibility}; use crate::style::Brush; + #[cfg(feature = "accesskit")] use accesskit::TextPosition; +#[cfg(feature = "accesskit")] +use parley_core::shape::Whitespace; /// Defines a position with a text layout. #[derive(Copy, Clone, PartialEq, Eq, Default, Debug)] diff --git a/parley/src/layout/alignment.rs b/parley/src/layout/alignment.rs index 26aa1144c..39e8ef106 100644 --- a/parley/src/layout/alignment.rs +++ b/parley/src/layout/alignment.rs @@ -1,13 +1,12 @@ // Copyright 2024 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use super::{ - BreakReason, - data::{ClusterData, LineItemData}, -}; +use super::{BreakReason, data::LineItemData}; use crate::data::LayoutData; use crate::style::Brush; +use parley_core::shape::ClusterData; + /// Alignment of a layout. #[derive(Copy, Clone, Default, PartialEq, Eq, Debug)] #[repr(u8)] diff --git a/parley/src/layout/cluster.rs b/parley/src/layout/cluster.rs index 40188785f..345fa3d9d 100644 --- a/parley/src/layout/cluster.rs +++ b/parley/src/layout/cluster.rs @@ -1,16 +1,16 @@ // Copyright 2021 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use crate::analysis::cluster::Whitespace; use crate::layout::Style; use crate::layout::data::BreakReason; -use crate::layout::data::ClusterData; -use crate::layout::glyph::Glyph; use crate::layout::layout::Layout; use crate::layout::line::{Line, LineItem}; use crate::layout::run::Run; use crate::style::Brush; + use core::ops::Range; +use parley_core::Glyph; +use parley_core::shape::{ClusterData, ClusterInfo, Whitespace}; /// Atomic unit of text. #[derive(Copy, Clone)] @@ -145,7 +145,8 @@ impl<'a, B: Brush> Cluster<'a, B> { /// Returns the range of text that defines the cluster. pub fn text_range(&self) -> Range { - self.data.text_range(self.run.data) + let start = self.run.data.text_range.start + self.data.text_offset as usize; + start..start + self.data.text_len as usize } /// Returns the style of the character this cluster represents. @@ -439,7 +440,7 @@ impl<'a, B: Brush> Cluster<'a, B> { Some(offset) } - pub(crate) fn info(&self) -> &super::data::ClusterInfo { + pub(crate) fn info(&self) -> &ClusterInfo { &self.data.info } diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index 81d6f3a7b..5b3509988 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -2,117 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use crate::inline_box::InlineBox; -use crate::layout::{ContentWidths, Glyph, LineMetrics, RunMetrics, Style}; +use crate::layout::{ContentWidths, LineMetrics, RunMetrics, Style}; use crate::style::Brush; use crate::util::nearly_zero; use crate::{FontData, IndentOptions, InlineBoxKind, LineHeight, OverflowWrap, TextWrapMode}; use core::ops::Range; use alloc::vec::Vec; -use parley_core::{Boundary, CharInfo}; - -use crate::analysis::cluster::Whitespace; - -#[derive(Copy, Clone, Debug, PartialEq)] -pub(crate) struct ClusterData { - pub(crate) info: ClusterInfo, - /// Cluster flags (see impl methods for details). - pub(crate) flags: u16, - /// Style index for this cluster. - pub(crate) style_index: u16, - /// Number of glyphs in this cluster (0xFF = single glyph stored inline) - pub(crate) glyph_len: u8, - /// Number of text bytes in this cluster - pub(crate) text_len: u8, - /// If `glyph_len == 0xFF`, then `glyph_offset` is a glyph identifier, - /// otherwise, it's an offset into the glyph array with the base - /// taken from the owning run. - pub(crate) glyph_offset: u32, - /// Offset into the text for this cluster - pub(crate) text_offset: u16, - /// Advance width for this cluster - pub(crate) advance: f32, -} - -impl ClusterData { - pub(crate) const LIGATURE_START: u16 = 1; - pub(crate) const LIGATURE_COMPONENT: u16 = 2; - - #[inline(always)] - pub(crate) fn is_ligature_start(self) -> bool { - self.flags & Self::LIGATURE_START != 0 - } - - #[inline(always)] - pub(crate) fn is_ligature_component(self) -> bool { - self.flags & Self::LIGATURE_COMPONENT != 0 - } - - #[inline(always)] - pub(crate) fn text_range(self, run: &RunData) -> Range { - let start = run.text_range.start + self.text_offset as usize; - start..start + self.text_len as usize - } -} - -#[derive(Copy, Clone, Debug, PartialEq)] -pub(crate) struct ClusterInfo { - boundary: Boundary, - source_char: char, -} - -impl ClusterInfo { - pub(crate) fn new(boundary: Boundary, source_char: char) -> Self { - Self { - boundary, - source_char, - } - } - - // Returns the boundary type of the cluster. - pub(crate) fn boundary(self) -> Boundary { - self.boundary - } - - // Returns the whitespace type of the cluster. - pub(crate) fn whitespace(self) -> Whitespace { - to_whitespace(self.source_char) - } - - /// Returns if the cluster is a line boundary. - pub(crate) fn is_boundary(self) -> bool { - self.boundary != Boundary::None - } - - /// Returns if the cluster is an emoji. - pub(crate) fn is_emoji(self) -> bool { - // TODO: Defer to ICU4X properties (see: https://docs.rs/icu/latest/icu/properties/props/struct.Emoji.html). - matches!(self.source_char as u32, 0x1F600..=0x1F64F | 0x1F300..=0x1F5FF | 0x1F680..=0x1F6FF | 0x2600..=0x26FF | 0x2700..=0x27BF) - } - - /// Returns if the cluster is any whitespace. - pub(crate) fn is_whitespace(self) -> bool { - self.source_char.is_whitespace() - } - - /// Returns the cluster's original character. - pub(crate) fn source_char(self) -> char { - self.source_char - } -} - -const fn to_whitespace(c: char) -> Whitespace { - const LINE_SEPARATOR: char = '\u{2028}'; - const PARAGRAPH_SEPARATOR: char = '\u{2029}'; - - match c { - ' ' => Whitespace::Space, - '\t' => Whitespace::Tab, - '\n' | '\r' | LINE_SEPARATOR | PARAGRAPH_SEPARATOR => Whitespace::Newline, - '\u{00A0}' => Whitespace::NoBreakSpace, - _ => Whitespace::None, - } -} +use parley_core::shape::{ClusterData, ClusterInfo, Whitespace, to_whitespace}; +use parley_core::{Boundary, CharInfo, Glyph}; /// `HarfRust`-based run data #[derive(Clone, Debug, PartialEq)] diff --git a/parley/src/layout/line.rs b/parley/src/layout/line.rs index 0b0e935fb..fa9a5a446 100644 --- a/parley/src/layout/line.rs +++ b/parley/src/layout/line.rs @@ -4,12 +4,13 @@ use crate::layout::Style; use crate::layout::data::BreakReason; use crate::layout::data::{LayoutItemKind, LineData}; -use crate::layout::glyph::Glyph; use crate::layout::layout::Layout; use crate::layout::run::Run; use crate::style::Brush; use crate::{InlineBox, InlineBoxKind}; + use core::ops::Range; +use parley_core::Glyph; /// Line in a text layout. #[derive(Copy, Clone)] diff --git a/parley/src/layout/line_break.rs b/parley/src/layout/line_break.rs index 39b090379..e4b6121c5 100644 --- a/parley/src/layout/line_break.rs +++ b/parley/src/layout/line_break.rs @@ -9,8 +9,6 @@ use alloc::vec::Vec; #[allow(unused_imports)] use core_maths::CoreFloat; -use crate::analysis::cluster::Whitespace; -use crate::data::ClusterData; use crate::layout::{ BreakReason, Layout, LayoutData, LayoutItem, LayoutItemKind, LineData, LineItemData, LineMetrics, Run, RunMetrics, @@ -20,6 +18,7 @@ use crate::{InlineBoxKind, OverflowWrap, TextWrapMode}; use core::ops::Range; use parley_core::Boundary; +use parley_core::shape::{ClusterData, Whitespace}; #[derive(Default)] struct LineLayout { diff --git a/parley/src/layout/mod.rs b/parley/src/layout/mod.rs index 439a1990d..adcb85800 100644 --- a/parley/src/layout/mod.rs +++ b/parley/src/layout/mod.rs @@ -7,7 +7,6 @@ mod accessibility; mod alignment; mod cluster; -mod glyph; mod line; mod line_break; mod run; @@ -19,6 +18,8 @@ mod run; )] mod layout; +pub use parley_core::Glyph; + pub(crate) mod data; #[cfg(feature = "accesskit")] @@ -26,7 +27,6 @@ pub use accessibility::LayoutAccessibility; pub use alignment::{Alignment, AlignmentOptions}; pub use cluster::{Affinity, Cluster, ClusterPath, ClusterSide}; pub use data::BreakReason; -pub use glyph::Glyph; pub use layout::Layout; pub use line::{GlyphRun, Line, LineMetrics, PositionedInlineBox, PositionedLayoutItem}; pub use line_break::{ diff --git a/parley/src/layout/run.rs b/parley/src/layout/run.rs index 944b4cb10..16c67fc2a 100644 --- a/parley/src/layout/run.rs +++ b/parley/src/layout/run.rs @@ -6,6 +6,7 @@ use crate::layout::cluster::{Cluster, ClusterPath}; use crate::layout::data::{LineItemData, RunData}; use crate::layout::layout::Layout; use crate::style::Brush; + use core::ops::Range; use fontique::Synthesis; diff --git a/parley/src/lib.rs b/parley/src/lib.rs index de4d91fbd..b36778469 100644 --- a/parley/src/lib.rs +++ b/parley/src/lib.rs @@ -111,10 +111,8 @@ pub use fontique; mod analysis; mod builder; mod context; -mod convert; mod font; mod inline_box; -mod lru_cache; mod resolve; mod shape; mod util; diff --git a/parley/src/shape/mod.rs b/parley/src/shape/mod.rs index af7dabf97..e6cc1eca5 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -4,62 +4,20 @@ //! Text shaping implementation using `harfrust`for shaping //! and `icu` for text analysis. -use alloc::vec::Vec; -use core::mem; -use harfrust::ShapeOptions; -use parley_core::{Analysis, AnalysisDataSources, CharInfo}; +use parley_core::shape::{CharCluster, Status}; +use parley_core::{Analysis, AnalysisDataSources, FontInstance, ShapeOptions, Shaper}; use super::layout::Layout; -use super::resolve::{ResolveContext, Resolved, ResolvedStyle}; +use super::resolve::{ResolveContext, ResolvedStyle}; use super::style::{Brush, FontFeature, FontVariation}; use crate::FontData; -use crate::analysis::cluster::{Char, CharCluster, Status}; -use crate::convert::script_to_harfrust; use crate::inline_box::InlineBox; -use crate::lru_cache::LruCache; use crate::util::nearly_eq; use fontique::Language; use fontique::{self, Query, QueryFamily, QueryFont}; use parlance::Script; -mod cache; - -pub(crate) struct ShapeContext { - shape_data_cache: LruCache, - shape_instance_cache: LruCache, - shape_plan_cache: LruCache, - unicode_buffer: Option, - features: Vec, - char_cluster: CharCluster, -} - -impl Default for ShapeContext { - fn default() -> Self { - const MAX_ENTRIES: usize = 16; - Self { - shape_data_cache: LruCache::new(MAX_ENTRIES), - shape_instance_cache: LruCache::new(MAX_ENTRIES), - shape_plan_cache: LruCache::new(MAX_ENTRIES), - unicode_buffer: Some(harfrust::UnicodeBuffer::new()), - features: Vec::new(), - char_cluster: CharCluster::default(), - } - } -} - -struct Item { - style_index: u16, - size: f32, - script: Script, - level: u8, - locale: Option, - variations: Resolved, - features: Resolved, - word_spacing: f32, - letter_spacing: f32, -} - #[allow(clippy::too_many_arguments)] pub(crate) fn shape_text<'a, B: Brush>( rcx: &'a ResolveContext, @@ -68,7 +26,7 @@ pub(crate) fn shape_text<'a, B: Brush>( inline_boxes: &[InlineBox], analysis: &Analysis, char_style_indices: &[u16], - scx: &mut ShapeContext, + scx: &mut Shaper, mut text: &str, layout: &mut Layout, analysis_data_sources: &AnalysisDataSources, @@ -143,29 +101,59 @@ pub(crate) fn shape_text<'a, B: Brush>( let style_index = char_style_indices[item.range.char_range.start]; let style = &styles[usize::from(style_index)]; - shape_item( - &mut fq, - rcx, - styles, - &Item { - style_index, - size: style.font_size, - level: item.bidi_level, - script: item.script, - locale: style.locale, - variations: style.font_variations, - features: style.font_features, - word_spacing: style.word_spacing, - letter_spacing: style.letter_spacing, - }, - scx, + let mut font_selector = + FontSelector::new(&mut fq, rcx, styles, style_index, item.script, style.locale); + + scx.shape_item( text, - &item.range.byte_range, - &item.range.char_range, - analysis.char_info(), - char_style_indices, - layout, + analysis, + &item, + &ShapeOptions { + language: style.locale, + font_size: style.font_size, + features: rcx.features(style.font_features).unwrap_or(&[]), + variations: rcx.variations(style.font_variations).unwrap_or(&[]), + char_style_indices, + }, + #[inline(always)] + |char_cluster| { + font_selector + .select_font(char_cluster, analysis_data_sources) + .map(|selected| FontInstance { + blob: selected.font.blob, + index: selected.font.index, + synthesis: selected.font.synthesis, + }) + }, analysis_data_sources, + #[inline(always)] + |shaped_run| { + let run_style_index = char_style_indices[shaped_run.range.char_range.start]; + let run_style = &styles[usize::from(run_style_index)]; + let segment_char_info = &analysis.char_info()[shaped_run.range.char_range.clone()]; + let segment_char_style_indices = + &char_style_indices[shaped_run.range.char_range.clone()]; + layout.data.push_run( + FontData::new(shaped_run.font.blob, shaped_run.font.index), + style.font_size, + fontique::Attributes { + width: run_style.font_width, + weight: run_style.font_weight, + style: run_style.font_style, + }, + shaped_run.font.synthesis, + shaped_run.glyph_buffer, + item.bidi_level, + run_style_index, + style.word_spacing, + style.letter_spacing, + &text[shaped_run.range.byte_range.clone()], + segment_char_info, + segment_char_style_indices, + shaped_run.range.byte_range, + shaped_run.coords, + ); + }, ); } @@ -175,312 +163,6 @@ pub(crate) fn shape_text<'a, B: Brush>( } } -// Rebuilds the provided `char_cluster` in-place using the existing allocation -// for the given grapheme `segment_text`, consuming items from `item_infos_iter`. -fn fill_cluster_in_place( - segment_text: &str, - item_infos_iter: &mut impl Iterator, - code_unit_offset_in_string: &mut usize, - char_cluster: &mut CharCluster, -) { - // Reset cluster but keep allocation - char_cluster.clear(); - - let mut force_normalize = false; - let mut is_emoji_or_pictograph = false; - let mut map_len: u8 = 0; - let start = *code_unit_offset_in_string as u32; - - for ((_, ch), (info, style_index)) in segment_text.char_indices().zip(item_infos_iter.by_ref()) - { - force_normalize |= info.force_normalize(); - // TODO - make emoji detection more complete, as per (except using composite Trie tables as - // much as possible: - // https://github.com/conor-93/parley/blob/4637d826732a1a82bbb3c904c7f47a16a21cceec/parley/src/shape/mod.rs#L221-L269 - is_emoji_or_pictograph |= info.is_emoji_or_pictograph(); - *code_unit_offset_in_string += ch.len_utf8(); - - // TODO: Explore ignoring other modifiers in determining `contributes_to_shaping`: - // regional indicators, subdivision flag tag sequences, skin tone modifiers - // See also: https://github.com/google/emoji-segmenter - - // If the color emoji has a non-printing variation selector, ignore the variation selector. - // Its presentation depends on the platform and font. - // - // e.g. - // - `U+270C + U+FE0F`: `✌`, force basic presentation - // - `U+270C + U+FE0F`: `✌️`, force emoji presentation - // - // - let is_emoji_with_non_printing_variation_selector = - is_emoji_or_pictograph && info.is_variation_selector(); - - let contributes_to_shaping = - info.contributes_to_shaping() && !is_emoji_with_non_printing_variation_selector; - if contributes_to_shaping { - map_len += 1; - } - - char_cluster.chars.push(Char { - ch, - contributes_to_shaping, - glyph_id: 0, - style_index, - is_control_character: info.is_control(), - }); - } - - // Finalize cluster metadata - let end = *code_unit_offset_in_string as u32; - char_cluster.is_emoji = is_emoji_or_pictograph; - char_cluster.map_len = map_len; - char_cluster.start = start; - char_cluster.end = end; - char_cluster.force_normalize = force_normalize; -} - -fn shape_item<'a, B: Brush>( - fq: &mut Query<'a>, - rcx: &'a ResolveContext, - styles: &'a [ResolvedStyle], - item: &Item, - scx: &mut ShapeContext, - text: &str, - text_range: &core::ops::Range, - char_range: &core::ops::Range, - char_info: &[CharInfo], - char_style_indices: &[u16], - layout: &mut Layout, - analysis_data_sources: &AnalysisDataSources, -) { - let item_text = &text[text_range.clone()]; - - // Only process current item - let item_char_info = &char_info[char_range.start..char_range.end]; - let item_char_style_indices = &char_style_indices[char_range.start..char_range.end]; - let first_style_index = item_char_style_indices[0]; - - let mut font_selector = - FontSelector::new(fq, rcx, styles, first_style_index, item.script, item.locale); - - let grapheme_cluster_boundaries = analysis_data_sources - .grapheme_segmenter() - .segment_str(item_text); - let mut item_infos_iter = item_char_info - .iter() - .copied() - .zip(item_char_style_indices.iter().copied()); - let mut code_unit_offset_in_string = text_range.start; - let char_cluster = &mut scx.char_cluster; - - // Build an iterator of boundaries and consume the first segment to seed the loop - let mut boundaries_iter = grapheme_cluster_boundaries.skip(1); - let mut last_boundary = 0_usize; - let Some(mut current_boundary) = boundaries_iter.next() else { - return; // No clusters - }; - - fill_cluster_in_place( - &item_text[last_boundary..current_boundary], - &mut item_infos_iter, - &mut code_unit_offset_in_string, - char_cluster, - ); - - let mut current_font = font_selector.select_font(char_cluster, analysis_data_sources); - - // Main segmentation loop (based on swash shape_clusters) - only within current item - while let Some(font) = current_font.take() { - // Collect all clusters for this font segment - let cluster_range = char_cluster.range(); - let segment_start_offset = cluster_range.start as usize - text_range.start; - let mut segment_end_offset = cluster_range.end as usize - text_range.start; - - for next_boundary in boundaries_iter.by_ref() { - // Build next cluster in-place - last_boundary = current_boundary; - current_boundary = next_boundary; - fill_cluster_in_place( - &item_text[last_boundary..current_boundary], - &mut item_infos_iter, - &mut code_unit_offset_in_string, - char_cluster, - ); - - if let Some(next_font) = font_selector.select_font(char_cluster, analysis_data_sources) - { - if next_font != font { - current_font = Some(next_font); - break; - } else { - // Same font - add to current segment - segment_end_offset = char_cluster.range().end as usize - text_range.start; - } - } else { - // No font determined, continue to next cluster - continue; - } - } - - // Shape this font segment with harfrust - let segment_text = &item_text[segment_start_offset..segment_end_offset]; - // Shape the entire segment text including newlines - // The line breaking algorithm will handle newlines automatically - - // TODO: How do we want to handle errors like this? - let font_ref = - harfrust::FontRef::from_index(font.font.blob.as_ref(), font.font.index).unwrap(); - - // Create harfrust shaper - let shaper_data = scx.shape_data_cache.entry( - cache::ShapeDataKey::new(font.font.blob.id(), font.font.index), - || harfrust::ShaperData::new(&font_ref), - ); - let instance = scx.shape_instance_cache.entry( - cache::ShapeInstanceKey::new( - font.font.blob.id(), - font.font.index, - &font.font.synthesis, - rcx.variations(item.variations), - ), - || { - harfrust::ShaperInstance::from_variations( - &font_ref, - variations_iter(&font.font.synthesis, rcx.variations(item.variations)), - ) - }, - ); - - let direction = if item.level & 1 != 0 { - harfrust::Direction::RightToLeft - } else { - harfrust::Direction::LeftToRight - }; - let hb_script = script_to_harfrust(item.script); - let language = item - .locale - .as_ref() - .and_then(|lang| lang.language().parse::().ok()); - scx.features.clear(); - for feature in rcx.features(item.features).unwrap_or(&[]) { - scx.features.push(harfrust::Feature::new( - harfrust::Tag::new(&feature.tag.to_bytes()), - feature.value as u32, - .., - )); - } - let harf_shaper = shaper_data - .shaper(&font_ref) - .instance(Some(instance)) - .build(); - let shaper_plan = scx.shape_plan_cache.entry( - cache::ShapePlanKey::new( - font.font.blob.id(), - font.font.index, - &font.font.synthesis, - direction, - hb_script, - language.clone(), - &scx.features, - rcx.variations(item.variations), - ), - || { - harfrust::ShapePlan::new( - &harf_shaper, - direction, - Some(hb_script), - language.as_ref(), - &scx.features, - ) - }, - ); - - // Prepare harfrust buffer - let mut buffer = mem::take(&mut scx.unicode_buffer).unwrap(); - buffer.clear(); - - // Use the entire segment text including newlines - buffer.reserve(segment_text.len()); - for (i, ch) in segment_text.chars().enumerate() { - // Ensure that each cluster's index matches the index into `infos`. This is required - // for efficient cluster lookup within `data.rs`. - // - // In other words, instead of using `buffer.push_str`, which iterates `segment_text` - // with `char_indices`, push each char individually via `.chars` with a cluster index - // that matches its `infos` counterpart. This allows us to lookup `infos` via cluster - // index in `data.rs`. - buffer.add(ch, i as u32); - } - - buffer.set_direction(direction); - - buffer.set_script(hb_script); - - if let Some(lang) = language { - buffer.set_language(lang); - } - - let glyph_buffer = harf_shaper.shape( - buffer, - ShapeOptions::new() - .plan(Some(shaper_plan)) - .features(&scx.features) - .point_size(Some(item.size)), - ); - - // Extract relevant CharInfo slice for this segment - let char_start = char_range.start + item_text[..segment_start_offset].chars().count(); - let segment_char_start = char_start - char_range.start; - let segment_char_count = segment_text.chars().count(); - let segment_char_info = - &item_char_info[segment_char_start..(segment_char_start + segment_char_count)]; - let segment_char_style_indices = - &item_char_style_indices[segment_char_start..(segment_char_start + segment_char_count)]; - - // Push harfrust-shaped run for the entire segment - layout.data.push_run( - FontData::new(font.font.blob.clone(), font.font.index), - item.size, - font.attrs, - font.font.synthesis, - &glyph_buffer, - item.level, - item.style_index, - item.word_spacing, - item.letter_spacing, - segment_text, - segment_char_info, - segment_char_style_indices, - (text_range.start + segment_start_offset)..(text_range.start + segment_end_offset), - harf_shaper.coords(), - ); - - // Replace buffer to reuse allocation in next iteration. - scx.unicode_buffer = Some(glyph_buffer.clear()); - } -} - -fn variations_iter<'a>( - synthesis: &'a fontique::Synthesis, - item: Option<&'a [FontVariation]>, -) -> impl Iterator + 'a { - synthesis - .variation_settings() - .iter() - .map(|(tag, value)| harfrust::Variation { - tag: *tag, - value: *value, - }) - .chain( - item.unwrap_or(&[]) - .iter() - .map(|variation| harfrust::Variation { - tag: harfrust::Tag::new(&variation.tag.to_bytes()), - value: variation.value, - }), - ) -} - struct FontSelector<'a, 'b, B: Brush> { query: &'b mut Query<'a>, fonts_id: Option, @@ -534,7 +216,7 @@ impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> { analysis_data_sources: &AnalysisDataSources, ) -> Option { let style_index = cluster.style_index(); - let is_emoji = cluster.is_emoji; + let is_emoji = cluster.is_emoji(); if style_index != self.style_index || is_emoji || self.fonts_id.is_none() { self.style_index = style_index; let style = &self.styles[style_index as usize]; @@ -589,25 +271,16 @@ impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> { match map_status { Status::Complete => { - selected_font = Some(SelectedFont { - font: font.clone(), - attrs: self.attrs, - }); + selected_font = Some(SelectedFont { font: font.clone() }); fontique::QueryStatus::Stop } Status::Keep => { - selected_font = Some(SelectedFont { - font: font.clone(), - attrs: self.attrs, - }); + selected_font = Some(SelectedFont { font: font.clone() }); fontique::QueryStatus::Continue } Status::Discard => { if selected_font.is_none() { - selected_font = Some(SelectedFont { - font: font.clone(), - attrs: self.attrs, - }); + selected_font = Some(SelectedFont { font: font.clone() }); } fontique::QueryStatus::Continue } @@ -619,7 +292,6 @@ impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> { struct SelectedFont { font: QueryFont, - attrs: fontique::Attributes, } impl PartialEq for SelectedFont { diff --git a/parley_core/Cargo.toml b/parley_core/Cargo.toml index c101c6f9e..cb9986a50 100644 --- a/parley_core/Cargo.toml +++ b/parley_core/Cargo.toml @@ -14,15 +14,19 @@ all-features = true [features] default = ["std"] -std = [] +std = ["fontique/std", "harfrust/std", "parlance/std"] +libm = ["fontique/libm", "harfrust/libm"] # Enables dictionary-based line and word breaking for complex scripts (CJK, Thai, Khmer, Lao, Myanmar). # When disabled, a lightweight segmenter is used that falls back to character-level breaks for those scripts. complex-scripts = [] [dependencies] +fontique = { workspace = true } parlance = { workspace = true } parley_data = { workspace = true, features = ["baked"] } +harfrust = { workspace = true } +hashbrown = { workspace = true } icu_normalizer = { workspace = true, features = ["compiled_data"] } icu_properties = { workspace = true, features = ["compiled_data"] } icu_segmenter = { workspace = true, features = ["compiled_data"] } diff --git a/parley/src/layout/glyph.rs b/parley_core/src/glyph.rs similarity index 85% rename from parley/src/layout/glyph.rs rename to parley_core/src/glyph.rs index 121e87573..f7fd6c098 100644 --- a/parley/src/layout/glyph.rs +++ b/parley_core/src/glyph.rs @@ -3,6 +3,7 @@ /// Glyph with an offset and advance. #[derive(Copy, Clone, Default, Debug, PartialEq)] +#[expect(missing_docs, reason = "Deferred")] pub struct Glyph { pub id: u32, pub x: f32, diff --git a/parley_core/src/lib.rs b/parley_core/src/lib.rs index 9022f8bac..f79cf35b1 100644 --- a/parley_core/src/lib.rs +++ b/parley_core/src/lib.rs @@ -27,7 +27,13 @@ mod analysis; mod analyzer; pub mod bidi; pub mod break_overrides; +mod glyph; pub mod itemize; +mod lru_cache; +pub mod shape; pub use analysis::{Analysis, AnalysisDataSources, Boundary, CharInfo}; pub use analyzer::{AnalysisOptions, Analyzer}; +pub use glyph::Glyph; +pub use shape::shaped_text::ShapedRun; +pub use shape::shaper::{FontInstance, ShapeOptions, Shaper}; diff --git a/parley/src/lru_cache.rs b/parley_core/src/lru_cache.rs similarity index 100% rename from parley/src/lru_cache.rs rename to parley_core/src/lru_cache.rs diff --git a/parley/src/shape/cache.rs b/parley_core/src/shape/cache.rs similarity index 99% rename from parley/src/shape/cache.rs rename to parley_core/src/shape/cache.rs index 6cfa2f788..cb8d3c50f 100644 --- a/parley/src/shape/cache.rs +++ b/parley_core/src/shape/cache.rs @@ -1,9 +1,9 @@ // Copyright 2025 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use crate::FontVariation; use alloc::boxed::Box; use hashbrown::Equivalent; +use parlance::FontVariation; #[derive(PartialEq, Copy, Clone)] pub(crate) struct ShapeDataKey { diff --git a/parley/src/analysis/cluster.rs b/parley_core/src/shape/cluster.rs similarity index 91% rename from parley/src/analysis/cluster.rs rename to parley_core/src/shape/cluster.rs index e0a08cea0..30764d319 100644 --- a/parley/src/analysis/cluster.rs +++ b/parley_core/src/shape/cluster.rs @@ -1,6 +1,9 @@ // Copyright 2025 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT +#![expect(missing_docs, reason = "Deferred")] +#![expect(missing_debug_implementations, reason = "Deferred")] + use alloc::vec::Vec; use icu_normalizer::properties::Decomposed; @@ -10,13 +13,13 @@ use crate::analysis::AnalysisDataSources; const MAX_CLUSTER_SIZE: usize = 32; #[derive(Debug, Default)] -pub(crate) struct CharCluster { - pub chars: Vec, - pub is_emoji: bool, - pub map_len: u8, - pub start: u32, - pub end: u32, - pub force_normalize: bool, +pub struct CharCluster { + pub(crate) chars: Vec, + pub(crate) is_emoji: bool, + pub(crate) map_len: u8, + pub(crate) start: u32, + pub(crate) end: u32, + pub(crate) force_normalize: bool, comp: Form, decomp: Form, form: FormKind, @@ -24,23 +27,34 @@ pub(crate) struct CharCluster { } impl CharCluster { - pub(crate) fn range(&self) -> SourceRange { + #[inline] + pub fn range(&self) -> SourceRange { SourceRange { start: self.start, end: self.end, } } + + #[inline(always)] + pub fn chars(&self) -> &[Char] { + &self.chars + } + + #[inline(always)] + pub fn is_emoji(&self) -> bool { + self.is_emoji + } } /// Source range of a cluster in code units. #[derive(Copy, Clone)] -pub(crate) struct SourceRange { +pub struct SourceRange { pub start: u32, pub end: u32, } #[derive(Copy, Clone, Debug, Default)] -pub(crate) struct Char { +pub struct Char { /// The character. pub ch: char, /// Whether the character @@ -59,7 +73,7 @@ pub(crate) type GlyphId = u16; /// Whitespace content of a cluster. #[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug)] #[repr(u8)] -pub(crate) enum Whitespace { +pub enum Whitespace { /// Not a space. None = 0, /// Standard space. @@ -74,14 +88,15 @@ pub(crate) enum Whitespace { impl Whitespace { /// Returns true for space or no break space. - pub(crate) fn is_space_or_nbsp(self) -> bool { + #[inline] + pub fn is_space_or_nbsp(self) -> bool { matches!(self, Self::Space | Self::NoBreakSpace) } } /// Iterative status of mapping a character cluster to nominal glyph identifiers. #[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub(crate) enum Status { +pub enum Status { /// Mapping should be skipped. Discard, /// The best mapping so far. @@ -91,6 +106,7 @@ pub(crate) enum Status { } impl CharCluster { + #[inline] pub(crate) fn clear(&mut self) { self.chars.clear(); self.is_emoji = false; @@ -111,7 +127,7 @@ impl CharCluster { /// Returns the primary style index for the cluster. #[inline(always)] - pub(crate) fn style_index(&self) -> u16 { + pub fn style_index(&self) -> u16 { self.chars[0].style_index } @@ -195,7 +211,7 @@ impl CharCluster { } } - pub(crate) fn map( + pub fn map( &mut self, f: impl Fn(char) -> GlyphId, analysis_data_sources: &AnalysisDataSources, @@ -211,7 +227,7 @@ impl CharCluster { ratio = self.comp.map(&f, &mut glyph_ids, self.best_ratio); if ratio > self.best_ratio { self.best_ratio = ratio; - self.form = FormKind::NFC; + self.form = FormKind::Nfc; if ratio >= 1. { return Status::Complete; } @@ -234,7 +250,7 @@ impl CharCluster { ratio = self.decomp.map(&f, &mut glyph_ids, self.best_ratio); if ratio > self.best_ratio { self.best_ratio = ratio; - self.form = FormKind::NFD; + self.form = FormKind::Nfd; if ratio >= 1. { return Status::Complete; } @@ -243,7 +259,7 @@ impl CharCluster { ratio = self.comp.map(&f, &mut glyph_ids, self.best_ratio); if ratio > self.best_ratio { self.best_ratio = ratio; - self.form = FormKind::NFC; + self.form = FormKind::Nfc; if ratio >= 1. { return Status::Complete; } @@ -259,12 +275,11 @@ impl CharCluster { } #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] -#[allow(clippy::upper_case_acronyms)] enum FormKind { #[default] Original, - NFD, - NFC, + Nfd, + Nfc, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -314,6 +329,7 @@ impl Form { } #[inline(always)] + #[expect(clippy::cast_possible_truncation, reason = "Deferred")] fn setup(&mut self) { self.map_len = (self .chars() diff --git a/parley_core/src/shape/data.rs b/parley_core/src/shape/data.rs new file mode 100644 index 000000000..539c16e1f --- /dev/null +++ b/parley_core/src/shape/data.rs @@ -0,0 +1,110 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +#![expect(missing_docs, reason = "Deferred")] + +use crate::{Boundary, shape::Whitespace}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ClusterData { + pub info: ClusterInfo, + /// Cluster flags (see impl methods for details). + pub flags: u16, + /// Style index for this cluster. + pub style_index: u16, + /// Number of glyphs in this cluster (0xFF = single glyph stored inline) + pub glyph_len: u8, + /// Number of text bytes in this cluster + pub text_len: u8, + /// If `glyph_len == 0xFF`, then `glyph_offset` is a glyph identifier, + /// otherwise, it's an offset into the glyph array with the base + /// taken from the owning run. + pub glyph_offset: u32, + /// Offset into the text for this cluster + pub text_offset: u16, + /// Advance width for this cluster + pub advance: f32, +} + +impl ClusterData { + pub const LIGATURE_START: u16 = 1; + pub const LIGATURE_COMPONENT: u16 = 2; + + #[inline(always)] + pub fn is_ligature_start(self) -> bool { + self.flags & Self::LIGATURE_START != 0 + } + + #[inline(always)] + pub fn is_ligature_component(self) -> bool { + self.flags & Self::LIGATURE_COMPONENT != 0 + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ClusterInfo { + boundary: Boundary, + source_char: char, +} + +impl ClusterInfo { + #[inline(always)] + pub fn new(boundary: Boundary, source_char: char) -> Self { + Self { + boundary, + source_char, + } + } + + // Returns the boundary type of the cluster. + #[inline(always)] + pub fn boundary(self) -> Boundary { + self.boundary + } + + // Returns the whitespace type of the cluster. + #[inline(always)] + pub fn whitespace(self) -> Whitespace { + to_whitespace(self.source_char) + } + + /// Returns if the cluster is a line boundary. + #[inline] + pub fn is_boundary(self) -> bool { + self.boundary != Boundary::None + } + + /// Returns if the cluster is an emoji. + #[inline] + pub fn is_emoji(self) -> bool { + // TODO: Defer to ICU4X properties (see: https://docs.rs/icu/latest/icu/properties/props/struct.Emoji.html). + matches!(self.source_char as u32, 0x1F600..=0x1F64F | 0x1F300..=0x1F5FF | 0x1F680..=0x1F6FF | 0x2600..=0x26FF | 0x2700..=0x27BF) + } + + /// Returns if the cluster is any whitespace. + #[inline(always)] + pub fn is_whitespace(self) -> bool { + self.source_char.is_whitespace() + } + + /// Returns the cluster's original character. + #[inline(always)] + pub fn source_char(self) -> char { + self.source_char + } +} + +// TODO: should become private when more of `parley`'s shaping is in `parley_core` +#[inline] +pub const fn to_whitespace(c: char) -> Whitespace { + const LINE_SEPARATOR: char = '\u{2028}'; + const PARAGRAPH_SEPARATOR: char = '\u{2029}'; + + match c { + ' ' => Whitespace::Space, + '\t' => Whitespace::Tab, + '\n' | '\r' | LINE_SEPARATOR | PARAGRAPH_SEPARATOR => Whitespace::Newline, + '\u{00A0}' => Whitespace::NoBreakSpace, + _ => Whitespace::None, + } +} diff --git a/parley_core/src/shape/mod.rs b/parley_core/src/shape/mod.rs new file mode 100644 index 000000000..4fa003c08 --- /dev/null +++ b/parley_core/src/shape/mod.rs @@ -0,0 +1,81 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Text shaping utilities. + +mod cache; +mod cluster; +mod data; +pub(crate) mod shaped_text; +pub(crate) mod shaper; + +use crate::CharInfo; + +pub use cluster::{Char, CharCluster, SourceRange, Status, Whitespace}; +pub use data::{ClusterData, ClusterInfo, to_whitespace}; + +/// Rebuilds the provided `char_cluster` in-place using the existing allocation +/// for the given grapheme `segment_text`, consuming items from `item_infos_iter`. +#[expect(clippy::cast_possible_truncation, reason = "Deferred")] +#[inline] +fn fill_cluster_in_place( + segment_text: &str, + item_infos_iter: &mut impl Iterator, + code_unit_offset_in_string: &mut usize, + char_cluster: &mut CharCluster, +) { + // Reset cluster but keep allocation + char_cluster.clear(); + + let mut force_normalize = false; + let mut is_emoji_or_pictograph = false; + let mut map_len: u8 = 0; + let start = *code_unit_offset_in_string as u32; + + for ((_, ch), (info, style_index)) in segment_text.char_indices().zip(item_infos_iter.by_ref()) + { + force_normalize |= info.force_normalize(); + // TODO - make emoji detection more complete, as per (except using composite Trie tables as + // much as possible: + // https://github.com/conor-93/parley/blob/4637d826732a1a82bbb3c904c7f47a16a21cceec/parley/src/shape/mod.rs#L221-L269 + is_emoji_or_pictograph |= info.is_emoji_or_pictograph(); + *code_unit_offset_in_string += ch.len_utf8(); + + // TODO: Explore ignoring other modifiers in determining `contributes_to_shaping`: + // regional indicators, subdivision flag tag sequences, skin tone modifiers + // See also: https://github.com/google/emoji-segmenter + + // If the color emoji has a non-printing variation selector, ignore the variation selector. + // Its presentation depends on the platform and font. + // + // e.g. + // - `U+270C + U+FE0F`: `✌`, force basic presentation + // - `U+270C + U+FE0F`: `✌️`, force emoji presentation + // + // + let is_emoji_with_non_printing_variation_selector = + is_emoji_or_pictograph && info.is_variation_selector(); + + let contributes_to_shaping = + info.contributes_to_shaping() && !is_emoji_with_non_printing_variation_selector; + if contributes_to_shaping { + map_len += 1; + } + + char_cluster.chars.push(Char { + ch, + contributes_to_shaping, + glyph_id: 0, + style_index, + is_control_character: info.is_control(), + }); + } + + // Finalize cluster metadata + let end = *code_unit_offset_in_string as u32; + char_cluster.is_emoji = is_emoji_or_pictograph; + char_cluster.map_len = map_len; + char_cluster.start = start; + char_cluster.end = end; + char_cluster.force_normalize = force_normalize; +} diff --git a/parley_core/src/shape/shaped_text.rs b/parley_core/src/shape/shaped_text.rs new file mode 100644 index 000000000..971e10d2e --- /dev/null +++ b/parley_core/src/shape/shaped_text.rs @@ -0,0 +1,55 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Data structures to store the result of shaping. + +use crate::{FontInstance, itemize::TextRange}; + +// TODO: The data here will probably get a shape roughly as follows. +// +// pub struct ShapedText { +// runs: Vec, +// clusters: Vec, +// glyphs: Vec, +// coords: Vec, +// fonts: Vec, +// features: Vec, +// ... +// } +// +// pub struct ShapedRun { +// /// Font size. +// pub font_size: f32, +// /// The range of text this run corresponds to. +// pub text_range: TextRange, +// /// This run's glyphs, as a range into [`ShapedText::glyphs`]. +// pub glyph_range: Range, +// /// The normalized variation coords of this run, as a range into [`ShapedText::coords`]. +// pub coords_range: Range, +// /// This run's font, as an index into [`ShapedText::fonts`]. +// pub font_index: Range, +// /// The bidi level of the run. +// pub bidi_level: u8, +// ... +// } + +/// A shaped run. This is a run of glyphs within an [`Item`][`crate::itemize::Item`] that can be +/// rendered with a single font. +/// +/// This struct will change shape, as it's currently provided in the callback of +/// [`crate::Shaper::shape_item`], but will become an encoding of a run within a larger +/// `parley_core::ShapedText`. +#[derive(Clone, Debug)] +pub struct ShapedRun<'a> { + /// The range within the original text this run corresponds to. + pub range: TextRange, + + /// The glyphs of the run. + pub glyph_buffer: &'a harfrust::GlyphBuffer, + + /// The font this run's glyphs come from. + pub font: FontInstance, + + /// Normalized font variation coordinates. + pub coords: &'a [harfrust::NormalizedCoord], +} diff --git a/parley_core/src/shape/shaper.rs b/parley_core/src/shape/shaper.rs new file mode 100644 index 000000000..a38154a5a --- /dev/null +++ b/parley_core/src/shape/shaper.rs @@ -0,0 +1,360 @@ +// Copyright 2026 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Shaping of text. + +use alloc::vec::Vec; +use core::mem; +use harfrust::ShapeOptions as HarfShapeOptions; +use parlance::{FontFeature, FontVariation, Language}; + +use crate::{ + Analysis, AnalysisDataSources, CharInfo, ShapedRun, + itemize::{Item, TextRange}, + lru_cache::LruCache, + shape::{CharCluster, cache, fill_cluster_in_place}, +}; + +/// Shaping options for one item. +/// +/// These are styling options relevant for shaping. They're styling, in that they're not derived +/// from the underlying text. When you [itemize][`Analysis::itemize`] the text, you should split the +/// text at points where these options change. +#[derive(Debug)] +pub struct ShapeOptions<'a> { + /// The font size to shape the item with. + pub font_size: f32, + /// The language to shape the item with. + pub language: Option, + /// The font features to shape the item with. + pub features: &'a [FontFeature], + /// The font variations that are constant over an item. + pub variations: &'a [FontVariation], + /// The per-character style indices. + // TODO: rename to something like `user_data` (s.t. we don't assume it's a style per se). + pub char_style_indices: &'a [u16], +} + +/// The font instance to shape an item with. +#[derive(Clone, Debug, PartialEq)] +pub struct FontInstance { + /// The font blob. + // TODO: perhaps use `raw_resource_handle` directly; i.e., perhaps we can remove the `fontique` + // dependency + pub blob: fontique::Blob, + /// The index of the font. + pub index: u32, + /// Font synthesis suggestions. + // TODO: Synthesis carries more than we need, and ties us to `fontique`. We can likely drop this + // in the future. + pub synthesis: fontique::Synthesis, +} + +/// Reusable scratch to shape [items][`Item`] into shaped text using [`Self::shape_item`]. +pub struct Shaper { + shape_data_cache: LruCache, + shape_instance_cache: LruCache, + shape_plan_cache: LruCache, + unicode_buffer: Option, + features: Vec, + char_cluster: CharCluster, +} + +impl Default for Shaper { + fn default() -> Self { + const MAX_ENTRIES: usize = 16; + Self { + shape_data_cache: LruCache::new(MAX_ENTRIES), + shape_instance_cache: LruCache::new(MAX_ENTRIES), + shape_plan_cache: LruCache::new(MAX_ENTRIES), + unicode_buffer: Some(harfrust::UnicodeBuffer::new()), + features: Vec::new(), + char_cluster: CharCluster::default(), + } + } +} + +impl core::fmt::Debug for Shaper { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Shaper").finish_non_exhaustive() + } +} + +impl Shaper { + /// Shape an [`Item`] produced by [`Analysis::itemize`] into glyphs. + /// + /// The item is broken into runs of maximal sequences of character clusters for which + /// `select_font` returns the same font. The `shaped_runs` callback is called with each shaped + /// run. + /// + /// `text` must be the same text as originally passed to create [`Analysis`]. `item` must be an + /// [`Item`] produced by [`Analysis::itemize`] on this text's analysis. + /// + /// The `select_font` callback should return the font to shape `char_cluster` with. If + /// consecutive character clusters select a different font, they become separately-shaped runs. + /// + /// # Panics + /// + /// Panics if the font returned by `select_font` isn't a parseable font. + /// + // TODO: For `select_font`, on `None`, the previous font is taken (and the run is dropped if + // `None` is returned on the first call). This is identical to Parley's old behavior, but we + // probably want the commented-out documented behavior that follows, as returning a font is + // cheap and it probably doesn't make a ton of sense to hardcode some font fallback behavior + // here. + // + // /// Return `None` if there are no fonts available at all. The character cluster's text will be + // /// omitted from the shaped result. Instead, you probably want to render a `.notdef` glyph from a + // /// font you do have available, in which case you can return the previous font or some + // /// last-resort fallback font instead. + // + // TODO: Once we have a `ShapedText`, this will probably take a `&mut ShapedText` instead. + pub fn shape_item( + &mut self, + text: &str, + analysis: &Analysis, + item: &Item, + options: &ShapeOptions<'_>, + select_font: impl FnMut(&mut CharCluster) -> Option, + analysis_data_sources: &AnalysisDataSources, + shaped_runs: impl FnMut(ShapedRun<'_>), + ) { + shape_item( + self, + text, + item, + options, + select_font, + analysis.char_info(), + analysis_data_sources, + shaped_runs, + ); + } +} + +fn shape_item( + scx: &mut Shaper, + text: &str, + item: &Item, + options: &ShapeOptions<'_>, + mut select_font: impl FnMut(&mut CharCluster) -> Option, + char_info: &[CharInfo], + analysis_data_sources: &AnalysisDataSources, + mut shaped_runs: impl FnMut(ShapedRun<'_>), +) { + let text_range = &item.range.byte_range; + let char_range = &item.range.char_range; + + let item_text = &text[text_range.clone()]; + + // Only process current item + let item_char_info = &char_info[char_range.start..char_range.end]; + let item_char_style_indices = &options.char_style_indices[char_range.start..char_range.end]; + + let grapheme_cluster_boundaries = analysis_data_sources + .grapheme_segmenter() + .segment_str(item_text); + let mut item_infos_iter = item_char_info + .iter() + .copied() + .zip(item_char_style_indices.iter().copied()); + let mut code_unit_offset_in_string = text_range.start; + let char_cluster = &mut scx.char_cluster; + + // Build an iterator of boundaries and consume the first segment to seed the loop + let mut boundaries_iter = grapheme_cluster_boundaries.skip(1); + let mut last_boundary = 0_usize; + let Some(mut current_boundary) = boundaries_iter.next() else { + return; // No clusters + }; + + fill_cluster_in_place( + &item_text[last_boundary..current_boundary], + &mut item_infos_iter, + &mut code_unit_offset_in_string, + char_cluster, + ); + + let mut current_font = select_font(char_cluster); + + // Main segmentation loop (based on swash shape_clusters) - only within current item + while let Some(font) = current_font.take() { + // Collect all clusters for this font segment + let cluster_range = char_cluster.range(); + let segment_start_offset = cluster_range.start as usize - text_range.start; + let mut segment_end_offset = cluster_range.end as usize - text_range.start; + + for next_boundary in boundaries_iter.by_ref() { + // Build next cluster in-place + last_boundary = current_boundary; + current_boundary = next_boundary; + fill_cluster_in_place( + &item_text[last_boundary..current_boundary], + &mut item_infos_iter, + &mut code_unit_offset_in_string, + char_cluster, + ); + + if let Some(next_font) = select_font(char_cluster) { + if next_font != font { + current_font = Some(next_font); + break; + } else { + // Same font - add to current segment + segment_end_offset = char_cluster.range().end as usize - text_range.start; + } + } else { + // No font determined, continue to next cluster + continue; + } + } + + // Shape this font segment with harfrust + let segment_text = &item_text[segment_start_offset..segment_end_offset]; + // Shape the entire segment text including newlines + // The line breaking algorithm will handle newlines automatically + + // TODO: How do we want to handle errors like this? + let font_ref = harfrust::FontRef::from_index(font.blob.as_ref(), font.index).unwrap(); + + // Create harfrust shaper + let shaper_data = scx + .shape_data_cache + .entry(cache::ShapeDataKey::new(font.blob.id(), font.index), || { + harfrust::ShaperData::new(&font_ref) + }); + let instance = scx.shape_instance_cache.entry( + cache::ShapeInstanceKey::new( + font.blob.id(), + font.index, + &font.synthesis, + Some(options.variations), + ), + || { + harfrust::ShaperInstance::from_variations( + &font_ref, + variations_iter(&font.synthesis, options.variations), + ) + }, + ); + + let direction = if item.bidi_level & 1 != 0 { + harfrust::Direction::RightToLeft + } else { + harfrust::Direction::LeftToRight + }; + let hb_script = script_to_harfrust(item.script); + let language = options + .language + .as_ref() + .and_then(|lang| lang.language().parse::().ok()); + scx.features.clear(); + for feature in options.features { + scx.features.push(harfrust::Feature::new( + harfrust::Tag::new(&feature.tag.to_bytes()), + feature.value as u32, + .., + )); + } + let harf_shaper = shaper_data + .shaper(&font_ref) + .instance(Some(instance)) + .build(); + let shaper_plan = scx.shape_plan_cache.entry( + cache::ShapePlanKey::new( + font.blob.id(), + font.index, + &font.synthesis, + direction, + hb_script, + language.clone(), + &scx.features, + Some(options.variations), + ), + || { + harfrust::ShapePlan::new( + &harf_shaper, + direction, + Some(hb_script), + language.as_ref(), + &scx.features, + ) + }, + ); + + // Prepare harfrust buffer + let mut buffer = mem::take(&mut scx.unicode_buffer).unwrap(); + buffer.clear(); + + // Use the entire segment text including newlines + buffer.reserve(segment_text.len()); + #[expect(clippy::cast_possible_truncation, reason = "Deferred")] + for (i, ch) in segment_text.chars().enumerate() { + // Ensure that each cluster's index matches the index into `infos`. This is required + // for efficient cluster lookup within `data.rs`. + // + // In other words, instead of using `buffer.push_str`, which iterates `segment_text` + // with `char_indices`, push each char individually via `.chars` with a cluster index + // that matches its `infos` counterpart. This allows us to lookup `infos` via cluster + // index in `data.rs`. + buffer.add(ch, i as u32); + } + + buffer.set_direction(direction); + + buffer.set_script(hb_script); + + if let Some(lang) = language { + buffer.set_language(lang); + } + + let glyph_buffer = harf_shaper.shape( + buffer, + HarfShapeOptions::new() + .plan(Some(shaper_plan)) + .features(&scx.features) + .point_size(Some(options.font_size)), + ); + + // Extract relevant CharInfo slice for this segment + let char_start = char_range.start + item_text[..segment_start_offset].chars().count(); + let segment_char_count = segment_text.chars().count(); + + shaped_runs(ShapedRun { + range: TextRange { + byte_range: (item.range.byte_range.start + segment_start_offset) + ..(item.range.byte_range.start + segment_end_offset), + char_range: char_start..char_start + segment_char_count, + }, + font: font.clone(), + glyph_buffer: &glyph_buffer, + coords: harf_shaper.coords(), + }); + + // Replace buffer to reuse allocation in next iteration. + scx.unicode_buffer = Some(glyph_buffer.clear()); + } +} + +#[inline] +fn variations_iter<'a>( + synthesis: &'a fontique::Synthesis, + item: &'a [FontVariation], +) -> impl Iterator + 'a { + synthesis + .variation_settings() + .iter() + .map(|(tag, value)| harfrust::Variation { + tag: *tag, + value: *value, + }) + .chain(item.iter().map(|variation| harfrust::Variation { + tag: harfrust::Tag::new(&variation.tag.to_bytes()), + value: variation.value, + })) +} + +pub(crate) fn script_to_harfrust(script: fontique::Script) -> harfrust::Script { + harfrust::Script::from_iso15924_tag(harfrust::Tag::new(&script.to_bytes())) + .unwrap_or(harfrust::script::UNKNOWN) +}