diff --git a/parley/src/analysis/cluster.rs b/parley/src/analysis/cluster.rs index 88d69f8f7..e2e21215b 100644 --- a/parley/src/analysis/cluster.rs +++ b/parley/src/analysis/cluster.rs @@ -1,27 +1,15 @@ // Copyright 2025 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use alloc::string::String; -use alloc::vec; -use alloc::vec::Vec; - -use crate::analysis::AnalysisDataSources; - /// The maximum number of characters in a single cluster. const MAX_CLUSTER_SIZE: usize = 32; #[derive(Debug, Default)] pub(crate) struct CharCluster { - pub chars: Vec, + pub style_index: u16, pub is_emoji: bool, - pub map_len: u8, pub start: u32, pub end: u32, - pub force_normalize: bool, - comp: Form, - decomp: Form, - form: FormKind, - best_ratio: f32, } impl CharCluster { @@ -40,23 +28,6 @@ pub(crate) struct SourceRange { pub end: u32, } -#[derive(Copy, Clone, Debug)] -pub(crate) struct Char { - /// The character. - pub ch: char, - /// Whether the character - pub is_control_character: bool, - /// True if the character should be considered when mapping glyphs. - pub contributes_to_shaping: bool, - /// Nominal glyph identifier. - pub glyph_id: GlyphId, - /// Indexes into the list of styles for the containing text run, to find the style applicable - /// to this character. - pub style_index: u16, -} - -pub(crate) type GlyphId = u16; - /// Whitespace content of a cluster. #[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug)] #[repr(u8)] @@ -80,346 +51,16 @@ impl Whitespace { } } -/// Iterative status of mapping a character cluster to nominal glyph identifiers. -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub(crate) enum Status { - /// Mapping should be skipped. - Discard, - /// The best mapping so far. - Keep, - /// Complete mapping. - Complete, -} - impl CharCluster { pub(crate) fn clear(&mut self) { - self.chars.clear(); + self.style_index = u16::MAX; self.is_emoji = false; - self.map_len = 0; self.start = 0; self.end = 0; - self.force_normalize = false; - self.comp.clear(); - self.decomp.clear(); - self.form = FormKind::Original; - self.best_ratio = 0.; - } - - fn len(&self) -> usize { - self.chars.len() } /// Returns the primary style index for the cluster. pub(crate) fn style_index(&self) -> u16 { - self.chars[0].style_index - } - - fn decomposed( - &mut self, - analysis_data_sources: &AnalysisDataSources, - scratch_string: &mut String, - ) -> Option<&[Char]> { - match self.decomp.state { - FormState::Invalid => None, - FormState::None => { - self.decomp.state = FormState::Invalid; - - // Create a string from the original characters to normalize - scratch_string.clear(); - for ch in &self.chars[..self.len()] { - scratch_string.push(ch.ch); - } - - // Normalize to NFD (decomposed) form - let nfd_str = analysis_data_sources - .decomposing_normalizer() - .normalize(scratch_string); - - // Copy the characters back to our form structure - let mut i = 0; - for c in nfd_str.chars() { - if i == MAX_CLUSTER_SIZE { - return None; - } - - // Use the first character as a template for other properties - let mut copy = self.chars[0]; - copy.ch = c; - if i >= self.decomp.chars.len() { - self.decomp.chars.push(copy); - } else { - self.decomp.chars[i] = copy; - } - i += 1; - } - - if i == 0 { - return None; - } - - self.decomp.len = i as u8; - self.decomp.state = FormState::Valid; - self.decomp.setup(); - Some(self.decomp.chars()) - } - FormState::Valid => Some(self.decomp.chars()), - } - } - - fn composed( - &mut self, - analysis_data_sources: &AnalysisDataSources, - scratch_string: &mut String, - ) -> Option<&[Char]> { - match self.comp.state { - FormState::Invalid => None, - FormState::None => { - // First, we need decomposed characters - if self - .decomposed(analysis_data_sources, scratch_string) - .map(|chars| chars.len()) - .unwrap_or(0) - == 0 - { - self.comp.state = FormState::Invalid; - return None; - } - - self.comp.state = FormState::Invalid; - - // Create a string from the decomposed characters to normalize - scratch_string.clear(); - for ch in &self.decomp.chars()[..self.decomp.len as usize] { - scratch_string.push(ch.ch); - } - - // Normalize to NFC (composed) form - let nfc_str = analysis_data_sources - .composing_normalizer() - .normalize(scratch_string); - - // Copy the characters back to our form structure - let mut i = 0; - for c in nfc_str.chars() { - if i >= MAX_CLUSTER_SIZE { - self.comp.state = FormState::Invalid; - return None; - } - - // Use the first decomposed character as a template for other properties - let mut ch_copy = self.decomp.chars[0]; - ch_copy.ch = c; - if i >= self.comp.chars.len() { - self.comp.chars.push(ch_copy); - } else { - self.comp.chars[i] = ch_copy; - } - i += 1; - } - - if i == 0 { - return None; - } - - self.comp.len = i as u8; - self.comp.state = FormState::Valid; - self.comp.setup(); - Some(self.comp.chars()) - } - FormState::Valid => Some(self.comp.chars()), - } - } - - pub(crate) fn map( - &mut self, - f: impl Fn(char) -> GlyphId, - analysis_data_sources: &AnalysisDataSources, - scratch_string: &mut String, - ) -> Status { - let len = self.len(); - if len == 0 { - return Status::Complete; - } - let mut glyph_ids = [0_u16; MAX_CLUSTER_SIZE]; - let prev_ratio = self.best_ratio; - let mut ratio; - if self.force_normalize - && self - .composed(analysis_data_sources, scratch_string) - .is_some() - { - ratio = self.comp.map(&f, &mut glyph_ids, self.best_ratio); - if ratio > self.best_ratio { - self.best_ratio = ratio; - self.form = FormKind::NFC; - if ratio >= 1. { - return Status::Complete; - } - } - } - ratio = Mapper { - chars: &mut self.chars[..len], - map_len: self.map_len.max(1), - } - .map(&f, &mut glyph_ids, self.best_ratio); - if ratio > self.best_ratio { - self.best_ratio = ratio; - self.form = FormKind::Original; - if ratio >= 1. { - return Status::Complete; - } - } - if len > 1 - && self - .decomposed(analysis_data_sources, scratch_string) - .is_some() - { - ratio = self.decomp.map(&f, &mut glyph_ids, self.best_ratio); - if ratio > self.best_ratio { - self.best_ratio = ratio; - self.form = FormKind::NFD; - if ratio >= 1. { - return Status::Complete; - } - } - if !self.force_normalize - && self - .composed(analysis_data_sources, scratch_string) - .is_some() - { - ratio = self.comp.map(&f, &mut glyph_ids, self.best_ratio); - if ratio > self.best_ratio { - self.best_ratio = ratio; - self.form = FormKind::NFC; - if ratio >= 1. { - return Status::Complete; - } - } - } - } - if self.best_ratio > prev_ratio { - Status::Keep - } else { - Status::Discard - } - } -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -#[allow(clippy::upper_case_acronyms)] -enum FormKind { - Original, - NFD, - NFC, -} - -impl Default for FormKind { - fn default() -> Self { - Self::Original - } -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum FormState { - None, - Valid, - Invalid, -} - -#[derive(Clone, Debug)] -pub(crate) struct Form { - chars: Vec, - len: u8, - map_len: u8, - state: FormState, -} - -impl Default for Form { - fn default() -> Self { - Self::new() - } -} - -impl Form { - fn new() -> Self { - Self { - chars: vec![], - len: 0, - map_len: 0, - state: FormState::None, - } - } - - fn clear(&mut self) { - self.chars.clear(); - self.len = 0; - self.map_len = 0; - self.state = FormState::None; - } - - fn chars(&self) -> &[Char] { - &self.chars[..self.len as usize] - } - - fn setup(&mut self) { - self.map_len = (self - .chars() - .iter() - .filter(|c| !c.is_control_character) - .count() as u8) - .max(1); - } - - fn map( - &mut self, - f: &impl Fn(char) -> u16, - glyphs: &mut [u16; MAX_CLUSTER_SIZE], - best_ratio: f32, - ) -> f32 { - Mapper { - chars: &mut self.chars[..self.len as usize], - map_len: self.map_len, - } - .map(f, glyphs, best_ratio) - } -} - -struct Mapper<'a> { - chars: &'a mut [Char], - map_len: u8, -} - -impl<'a> Mapper<'a> { - fn map( - &mut self, - f: &impl Fn(char) -> u16, - glyphs: &mut [u16; MAX_CLUSTER_SIZE], - best_ratio: f32, - ) -> f32 { - if self.map_len == 0 { - return 1.; - } - let mut mapped = 0; - for (c, g) in self.chars.iter().zip(glyphs.iter_mut()) { - if !c.contributes_to_shaping { - *g = f(c.ch); - if self.map_len == 1 { - mapped += 1; - } - } else { - let gid = f(c.ch); - *g = gid; - if gid != 0 { - mapped += 1; - } - } - } - let ratio = mapped as f32 / self.map_len as f32; - if ratio > best_ratio { - for (ch, glyph) in self.chars.iter_mut().zip(glyphs) { - ch.glyph_id = *glyph; - } - } - ratio + self.style_index } } diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index b93a7534d..2d8cc8b45 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -293,6 +293,78 @@ pub(crate) struct LayoutData { pub(crate) alignment_width: f32, } +/// Represents a contiguous range of unshaped clusters (glyphs with id 0) that need +/// to be reshaped with a fallback font. +#[derive(Clone, Debug)] +pub(crate) struct Hole { + /// Char index range within the segment's char_infos slice. + /// Used to extract the CharInfo slice for reshaping. + pub char_range: Range, + /// Text byte range within the segment text. + /// Used to extract the substring for reshaping. + pub text_range: Range, +} + +/// A segment of shaped text, either successfully shaped or a hole needing fallback. +#[derive(Clone, Debug)] +pub(crate) enum ShapedSegment { + /// Successfully shaped - glyphs are valid + Shaped { + /// Cluster index range (0-based within this shaping result) + cluster_range: Range, + /// Text byte range within the shaped text + text_range: Range, + }, + /// A hole - glyphs have glyph_id == 0, needs fallback font + Hole(Hole), +} + +/// Result of analyzing a glyph buffer for holes. +/// Contains alternating shaped and hole segments in text order. +#[derive(Clone, Debug)] +pub(crate) struct ShapedRunAnalysis { + /// Segments in logical (text) order + pub segments: Vec, + /// Whether any holes were found + pub has_holes: bool, +} + +/// Temporarily holds processed cluster/glyph data before deciding whether to push to layout. +/// This allows us to analyze for holes and then either push entirely or split by segments. +#[derive(Debug)] +#[allow(dead_code)] +pub(crate) struct ProcessedRun { + /// Processed clusters (in logical order) + pub clusters: Vec, + /// Processed glyphs + pub glyphs: Vec, + /// Total advance of the run + pub advance: f32, + /// Run metrics + pub metrics: RunMetrics, + /// Font data for this run + pub font_index: usize, + pub font_size: f32, + pub synthesis: fontique::Synthesis, + pub coords: Vec, + /// Text info + pub bidi_level: u8, + pub style_index: u16, + pub word_spacing: f32, + pub letter_spacing: f32, + pub text_range: Range, +} + +impl ProcessedRun { + /// Take allocations for later reuse. + pub(crate) fn take_vecs(&mut self) -> (Vec, Vec) { + ( + core::mem::take(&mut self.clusters), + core::mem::take(&mut self.glyphs), + ) + } +} + impl Default for LayoutData { fn default() -> Self { Self { @@ -352,8 +424,11 @@ impl LayoutData { bidi_level, }); } + + /// Process a glyph buffer into a `ProcessedRun` without pushing to layout. + /// This allows analyzing for holes before deciding how to push. #[allow(clippy::too_many_arguments)] - pub(crate) fn push_run( + pub(crate) fn process_run_to_temp( &mut self, font: FontData, font_size: f32, @@ -364,14 +439,13 @@ impl LayoutData { word_spacing: f32, letter_spacing: f32, source_text: &str, - char_infos: &[(CharInfo, u16)], // From text analysis - text_range: Range, // The text range this run covers - coords: &[harfrust::NormalizedCoord], - ) { - let coords_start = self.coords.len(); - self.coords.extend(coords.iter().map(|c| c.to_bits())); - let coords_end = self.coords.len(); - + char_infos: &[(CharInfo, u16)], + text_range: Range, + coords: Vec, + cached_metrics: &crate::shape::cache::CachedMetrics, + mut clusters: Vec, + mut glyphs: Vec, + ) -> ProcessedRun { let font_index = self .fonts .iter() @@ -382,95 +456,56 @@ impl LayoutData { index }); - let metrics = { - let font = &self.fonts[font_index]; - let font_ref = skrifa::FontRef::from_index(font.data.as_ref(), font.index).unwrap(); - skrifa::metrics::Metrics::new(&font_ref, skrifa::prelude::Size::new(font_size), coords) - }; - let units_per_em = metrics.units_per_em as f32; - - let metrics = { - let (underline_offset, underline_size) = if let Some(underline) = metrics.underline { - (underline.offset, underline.thickness) - } else { - // Default values from Harfbuzz: https://github.com/harfbuzz/harfbuzz/blob/00492ec7df0038f41f78d43d477c183e4e4c506e/src/hb-ot-metrics.cc#L334 - let default = units_per_em / 18.0; - (default, default) - }; - let (strikethrough_offset, strikethrough_size) = - if let Some(strikeout) = metrics.strikeout { - (strikeout.offset, strikeout.thickness) - } else { - // Default values from HarfBuzz: https://github.com/harfbuzz/harfbuzz/blob/00492ec7df0038f41f78d43d477c183e4e4c506e/src/hb-ot-metrics.cc#L334-L347 - (metrics.ascent / 2.0, units_per_em / 18.0) - }; - - // Compute line height - let style = &self.styles[style_index as usize]; - let line_height = match style.line_height { - LineHeight::Absolute(value) => value, - LineHeight::FontSizeRelative(value) => value * font_size, - LineHeight::MetricsRelative(value) => { - (metrics.ascent - metrics.descent + metrics.leading) * value - } - }; - - RunMetrics { - ascent: metrics.ascent, - descent: -metrics.descent, - leading: metrics.leading, - underline_offset, - underline_size, - strikethrough_offset, - strikethrough_size, - line_height, + // Compute line height from style + let style = &self.styles[style_index as usize]; + let line_height = match style.line_height { + LineHeight::Absolute(value) => value, + LineHeight::FontSizeRelative(value) => value * font_size, + LineHeight::MetricsRelative(value) => { + (cached_metrics.ascent + cached_metrics.descent + cached_metrics.leading) * value } }; - - let cluster_range = self.clusters.len()..self.clusters.len(); - - let mut run = RunData { - font_index, - font_size, - synthesis, - coords_range: coords_start..coords_end, - text_range, - bidi_level, - cluster_range, - glyph_start: self.glyphs.len(), - metrics, - word_spacing, - letter_spacing, - advance: 0., - }; - - // `HarfRust` returns glyphs in visual order, so we need to process them as such while - // maintaining logical ordering of clusters. + let run_metrics = cached_metrics.to_run_metrics(line_height); let glyph_infos = glyph_buffer.glyph_infos(); if glyph_infos.is_empty() { - return; + return ProcessedRun { + clusters, + glyphs, + advance: 0.0, + metrics: run_metrics, + font_index, + font_size, + synthesis, + coords, + bidi_level, + style_index, + word_spacing, + letter_spacing, + text_range, + }; } + let glyph_positions = glyph_buffer.glyph_positions(); - let scale_factor = font_size / units_per_em; - let cluster_range_start = self.clusters.len(); + let scale_factor = font_size / cached_metrics.units_per_em; let is_rtl = bidi_level & 1 == 1; - if !is_rtl { - run.advance = process_clusters( + + let advance = if !is_rtl { + process_clusters( Direction::Ltr, - &mut self.clusters, - &mut self.glyphs, + &mut clusters, + &mut glyphs, scale_factor, glyph_infos, glyph_positions, char_infos, source_text.char_indices(), - ); + ) } else { - run.advance = process_clusters( + let advance = process_clusters( Direction::Rtl, - &mut self.clusters, - &mut self.glyphs, + &mut clusters, + &mut glyphs, scale_factor, glyph_infos, glyph_positions, @@ -478,19 +513,176 @@ impl LayoutData { source_text.char_indices().rev(), ); // Reverse clusters into logical order for RTL - let clusters_len = self.clusters.len(); - self.clusters[cluster_range_start..clusters_len].reverse(); + clusters.reverse(); + advance + }; + + ProcessedRun { + clusters, + glyphs, + advance, + metrics: run_metrics, + font_index, + font_size, + synthesis, + coords, + bidi_level, + style_index, + word_spacing, + letter_spacing, + text_range, } + } - run.cluster_range = cluster_range_start..self.clusters.len(); - if !run.cluster_range.is_empty() { - self.runs.push(run); - self.items.push(LayoutItem { - kind: LayoutItemKind::TextRun, - index: self.runs.len() - 1, - bidi_level, + /// Analyzes a glyph buffer and returns segments (shaped vs holes) in text order. + /// This is used to split shaped data for hole-based font fallback. + /// + /// The `holes` vec is used as scratch space and will be cleared/reused. + pub(crate) fn analyze_processed_run( + run: &ProcessedRun, + text_len: usize, + holes: &mut Vec, + ) -> ShapedRunAnalysis { + let clusters = &run.clusters; + let glyphs = &run.glyphs; + detect_holes(clusters, glyphs, holes); + + if holes.is_empty() { + // No holes - return empty segments (caller checks has_holes first) + return ShapedRunAnalysis { + segments: Vec::new(), + has_holes: false, + }; + } + + // Build segments from holes + let mut segments = Vec::new(); + let mut last_end_cluster = 0; + let mut last_end_text = 0; + + for hole in holes.iter() { + // Add shaped segment before this hole (if any) + if hole.char_range.start > last_end_cluster { + segments.push(ShapedSegment::Shaped { + cluster_range: last_end_cluster..hole.char_range.start, + text_range: last_end_text..hole.text_range.start, + }); + } + + // Add the hole + segments.push(ShapedSegment::Hole(hole.clone())); + + last_end_cluster = hole.char_range.end; + last_end_text = hole.text_range.end; + } + + // Add trailing shaped segment (if any) + if last_end_cluster < clusters.len() { + segments.push(ShapedSegment::Shaped { + cluster_range: last_end_cluster..clusters.len(), + text_range: last_end_text..text_len, }); } + + ShapedRunAnalysis { + segments, + has_holes: true, + } + } + + /// Push a segment from a ProcessedRun to the layout. + /// The segment_cluster_range is the range of clusters within the ProcessedRun to push. + /// text_range is the absolute text range for this segment. + pub(crate) fn push_processed_segment( + &mut self, + run: &ProcessedRun, + segment_cluster_range: Range, + text_range: Range, + ) { + if segment_cluster_range.is_empty() { + return; + } + + let coords_start = self.coords.len(); + self.coords.extend(run.coords.iter().copied()); + let coords_end = self.coords.len(); + + let cluster_range_start = self.clusters.len(); + let glyph_start = self.glyphs.len(); + + let segment_clusters = &run.clusters[segment_cluster_range.clone()]; + + // Calculate glyph range from cluster data + let mut segment_advance = 0.0f32; + let mut min_glyph_offset = usize::MAX; + let mut max_glyph_end = 0usize; + + for cluster in segment_clusters { + segment_advance += cluster.advance; + if cluster.glyph_len != 0xFF && cluster.glyph_len > 0 { + let start = cluster.glyph_offset as usize; + let end = start + cluster.glyph_len as usize; + min_glyph_offset = min_glyph_offset.min(start); + max_glyph_end = max_glyph_end.max(end); + } + } + + let glyph_base_offset = if min_glyph_offset < usize::MAX { + self.glyphs + .extend_from_slice(&run.glyphs[min_glyph_offset..max_glyph_end]); + min_glyph_offset + } else { + 0 + }; + + // Calculate text offset adjustment (first cluster's text_offset is the base) + let text_base_offset = segment_clusters + .first() + .map(|c| c.text_offset as usize) + .unwrap_or(0); + + // Copy clusters with adjusted offsets + for cluster in segment_clusters { + let mut new_cluster = *cluster; + + // Adjust glyph offset if not inlined + if new_cluster.glyph_len != 0xFF && new_cluster.glyph_len > 0 { + new_cluster.glyph_offset = + (new_cluster.glyph_offset as usize - glyph_base_offset) as u32; + } + + // Adjust text offset relative to segment start + new_cluster.text_offset = (new_cluster.text_offset as usize - text_base_offset) as u16; + + self.clusters.push(new_cluster); + } + + let run_data = RunData { + font_index: run.font_index, + font_size: run.font_size, + synthesis: run.synthesis.clone(), + coords_range: coords_start..coords_end, + text_range, + bidi_level: run.bidi_level, + cluster_range: cluster_range_start..self.clusters.len(), + glyph_start, + metrics: run.metrics.clone(), + word_spacing: run.word_spacing, + letter_spacing: run.letter_spacing, + advance: segment_advance, + }; + + self.runs.push(run_data); + self.items.push(LayoutItem { + kind: LayoutItemKind::TextRun, + index: self.runs.len() - 1, + bidi_level: run.bidi_level, + }); + } + + /// Push an entire ProcessedRun to the layout (no splitting). + pub(crate) fn push_processed_run(&mut self, run: &ProcessedRun) { + self.push_processed_segment(run, 0..run.clusters.len(), run.text_range.clone()); } pub(crate) fn finish(&mut self) { @@ -599,6 +791,133 @@ impl LayoutData { } } +/// Detects holes (clusters with glyph_id == 0) in the processed clusters. +/// +/// Returns a list of holes, where each hole is a contiguous range of clusters that have +/// glyph_id == 0 (meaning the font couldn't render those characters). +/// +/// Combining marks are always included with their preceding base character's hole, +/// even if the font claims to support the mark in isolation. This ensures proper rendering +/// of composed characters like Arabic letters with diacritics. +/// +/// Marks are detected as glyphs with 0 advance. +/// +/// Note: `glyphs` should be the slice of glyphs for this run only (not the entire layout). +/// The `holes` vec is cleared at the start and filled with detected holes. +fn detect_holes(clusters: &[ClusterData], glyphs: &[Glyph], holes: &mut Vec) { + holes.clear(); + + let mut hole_start: Option = None; // char index where hole starts + let mut hole_text_start: Option = None; // text offset where hole starts + + // First pass: determine which clusters are holes, handling ligature components. + // A ligature component (glyph_len=0) is a hole if its "owner" cluster is a hole. + // TODO: Reuse allocation. + let mut is_hole_vec: Vec = Vec::with_capacity(clusters.len()); + + for cluster in clusters.iter() { + is_hole_vec.push(cluster_is_hole(cluster, glyphs)); + } + + // Handle ligature components: if cluster[i] has glyph_len=0, it's part of a ligature/combining sequence. + // The "owner" cluster (the one with the actual glyphs) could be before OR after this cluster + // depending on LTR or RTL. + // + // TODO: I think this code isn't correct and can be improved significantly. + for i in 0..clusters.len() { + if clusters[i].glyph_len == 0 { + // TODO: Fix this code + + // Look backward + let mut found = false; + if i > 0 { + for j in (0..i).rev() { + if clusters[j].glyph_len != 0 { + is_hole_vec[i] = is_hole_vec[j]; + found = true; + break; + } + } + } + // TODO: Not sure if this isn't required + + // Look forward + if !found { + for j in (i + 1)..clusters.len() { + if clusters[j].glyph_len != 0 { + is_hole_vec[i] = is_hole_vec[j]; + break; + } + } + } + } + } + + for (i, cluster) in clusters.iter().enumerate() { + let is_hole = is_hole_vec[i]; + let is_combining = cluster.advance == 0.0; + + // A cluster is considered part of a hole if: + // 1. It has glyph_id == 0 (font can't render it), OR + // 2. It's a combining mark and the previous cluster was a hole + // (combining marks should stay with their base character) + let should_be_in_hole = is_hole || (is_combining && hole_start.is_some()); + + if should_be_in_hole { + // Start or continue a hole + if hole_start.is_none() { + hole_start = Some(i); + hole_text_start = Some(cluster.text_offset as usize); + } + } else if let Some(start_idx) = hole_start.take() { + // End the current hole + let text_start = hole_text_start.take().unwrap(); + let text_end = cluster.text_offset as usize; + holes.push(Hole { + char_range: start_idx..i, + text_range: text_start..text_end, + }); + } + } + + // Handle hole at the end + if let Some(start_idx) = hole_start { + let text_start = hole_text_start.unwrap(); + // For the last hole, compute end from the last cluster + if let Some(last_cluster) = clusters.last() { + let text_end = last_cluster.text_offset as usize + last_cluster.text_len as usize; + holes.push(Hole { + char_range: start_idx..clusters.len(), + text_range: text_start..text_end, + }); + } + } +} + +/// Check if a cluster is a "hole" (font couldn't render it, glyph_id == 0). +/// Note: `glyphs` should be the slice of glyphs for this run (already offset by glyph_base). +fn cluster_is_hole(cluster: &ClusterData, glyphs: &[Glyph]) -> bool { + let is_hole = if cluster.glyph_len == 0xFF { + // Single glyph inlined - glyph_offset IS the glyph ID + cluster.glyph_offset == 0 + } else if cluster.glyph_len == 0 { + // No glyphs (ligature component) - not a hole by itself + false + } else { + // Multiple glyphs - check if ALL are .notdef (id == 0) + // glyph_offset is already relative to the run's glyph_start + let start = cluster.glyph_offset as usize; + let end = start + cluster.glyph_len as usize; + if end <= glyphs.len() { + glyphs[start..end].iter().all(|g| g.id == 0) + } else { + false + } + }; + + is_hole +} + /// Processes shaped glyphs from `HarfRust` and converts them into `ClusterData` and `Glyph`. /// /// # Parameters @@ -616,6 +935,9 @@ impl LayoutData { /// * `char_infos` - Character information from text analysis, indexed by cluster ID. /// * `char_indices_iter` - Iterator over (`byte_offset`, `char`) pairs from the source text. /// Should be in logical order (forward for LTR, reverse for RTL). +/// +/// # Returns +/// * `f32` - Total advance of the run. fn process_clusters>( direction: Direction, clusters: &mut Vec, diff --git a/parley/src/shape/cache.rs b/parley/src/shape/cache.rs index 6cfa2f788..7bc754ec5 100644 --- a/parley/src/shape/cache.rs +++ b/parley/src/shape/cache.rs @@ -2,9 +2,131 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use crate::FontVariation; +use crate::layout::RunMetrics; use alloc::boxed::Box; +use alloc::vec::Vec; use hashbrown::Equivalent; +/// Cached font metrics to avoid repeated computation. +#[derive(Clone)] +pub(crate) struct CachedMetrics { + pub units_per_em: f32, + pub ascent: f32, + pub descent: f32, + pub leading: f32, + pub underline_offset: f32, + pub underline_size: f32, + pub strikethrough_offset: f32, + pub strikethrough_size: f32, +} + +impl CachedMetrics { + /// Convert to RunMetrics with the given line height. + pub(crate) fn to_run_metrics(&self, line_height: f32) -> RunMetrics { + RunMetrics { + ascent: self.ascent, + descent: self.descent, + leading: self.leading, + underline_offset: self.underline_offset, + underline_size: self.underline_size, + strikethrough_offset: self.strikethrough_offset, + strikethrough_size: self.strikethrough_size, + line_height, + } + } +} + +/// Cache key for font metrics (font + size + variations). +#[derive(PartialEq, Clone)] +pub(crate) struct MetricsCacheId { + font_blob_id: u64, + font_index: u32, + font_size_bits: u32, // f32 as bits for equality + coords: Box<[i16]>, +} + +/// Borrowed key for looking up cached metrics. +/// Uses harfrust coords directly to avoid allocation during lookup. +pub(crate) struct MetricsCacheKey<'a> { + font_blob_id: u64, + font_index: u32, + font_size_bits: u32, + coords: &'a [harfrust::NormalizedCoord], +} + +impl<'a> MetricsCacheKey<'a> { + pub(crate) fn new( + font_blob_id: u64, + font_index: u32, + font_size: f32, + coords: &'a [harfrust::NormalizedCoord], + ) -> Self { + Self { + font_blob_id, + font_index, + font_size_bits: font_size.to_bits(), + coords, + } + } +} + +impl<'a> Equivalent for MetricsCacheKey<'a> { + #[inline(always)] + fn equivalent(&self, key: &MetricsCacheId) -> bool { + self.font_blob_id == key.font_blob_id + && self.font_index == key.font_index + && self.font_size_bits == key.font_size_bits + && self.coords.len() == key.coords.len() + && self + .coords + .iter() + .zip(key.coords.iter()) + .all(|(a, b)| a.to_bits() == *b) + } +} + +impl<'a> From> for MetricsCacheId { + #[inline(always)] + fn from(key: MetricsCacheKey<'a>) -> Self { + Self { + font_blob_id: key.font_blob_id, + font_index: key.font_index, + font_size_bits: key.font_size_bits, + // Only allocate when inserting into cache + coords: key.coords.iter().map(|c| c.to_bits()).collect(), + } + } +} + +/// Pool for reusing Vec allocations when processing runs. +pub(crate) struct VecPool { + pool: Vec>, + max_size: usize, +} + +impl VecPool { + /// Create a new pool with the specified maximum size. + pub(crate) fn new(max_size: usize) -> Self { + Self { + pool: Vec::new(), + max_size, + } + } + + /// Acquire a Vec from the pool, or create a new one if empty. + pub(crate) fn acquire(&mut self) -> Vec { + self.pool.pop().unwrap_or_default() + } + + /// Return a Vec to the pool for reuse. The Vec is cleared before storing. + pub(crate) fn release(&mut self, mut vec: Vec) { + vec.clear(); + if self.pool.len() < self.max_size { + self.pool.push(vec); + } + } +} + #[derive(PartialEq, Copy, Clone)] pub(crate) struct ShapeDataKey { /// The font collection's blob ID. diff --git a/parley/src/shape/mod.rs b/parley/src/shape/mod.rs index 671e950ec..ce350cd8e 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -4,16 +4,14 @@ //! Text shaping implementation using `harfrust`for shaping //! and `icu` for text analysis. -use core::mem; use core::ops::RangeInclusive; -use alloc::string::String; use alloc::vec::Vec; use super::layout::Layout; use super::resolve::{RangedStyle, ResolveContext, Resolved}; use super::style::{Brush, FontFeature, FontVariation}; -use crate::analysis::cluster::{Char, CharCluster, Status}; +use crate::analysis::cluster::CharCluster; use crate::analysis::{AnalysisDataSources, CharInfo}; use crate::icu_convert::script_to_harfrust; use crate::inline_box::InlineBox; @@ -25,29 +23,39 @@ use icu_properties::props::Script; use fontique::{self, Query, QueryFamily, QueryFont}; -mod cache; +pub(crate) mod cache; pub(crate) struct ShapeContext { shape_data_cache: LruCache, shape_instance_cache: LruCache, shape_plan_cache: LruCache, + metrics_cache: LruCache, unicode_buffer: Option, features: Vec, - scratch_string: String, char_cluster: CharCluster, + // Allocation pools + cluster_pool: cache::VecPool, + glyph_pool: cache::VecPool, + tried_fonts_pool: cache::VecPool, + hole_pool: cache::VecPool, } impl Default for ShapeContext { fn default() -> Self { - const MAX_ENTRIES: usize = 16; + const MAX_CACHE_ENTRIES: usize = 16; + const MAX_POOL_SIZE: usize = 4; Self { - shape_data_cache: LruCache::new(MAX_ENTRIES), - shape_instance_cache: LruCache::new(MAX_ENTRIES), - shape_plan_cache: LruCache::new(MAX_ENTRIES), + shape_data_cache: LruCache::new(MAX_CACHE_ENTRIES), + shape_instance_cache: LruCache::new(MAX_CACHE_ENTRIES), + shape_plan_cache: LruCache::new(MAX_CACHE_ENTRIES), + metrics_cache: LruCache::new(MAX_CACHE_ENTRIES), unicode_buffer: Some(harfrust::UnicodeBuffer::new()), features: Vec::new(), - scratch_string: String::new(), char_cluster: CharCluster::default(), + cluster_pool: cache::VecPool::new(MAX_POOL_SIZE), + glyph_pool: cache::VecPool::new(MAX_POOL_SIZE), + tried_fonts_pool: cache::VecPool::new(MAX_POOL_SIZE), + hole_pool: cache::VecPool::new(MAX_POOL_SIZE), } } } @@ -230,37 +238,29 @@ fn fill_cluster_in_place( char_cluster: &mut CharCluster, ) { // Reset cluster but keep allocation + // TODO: Remove force normalise from info. char_cluster.clear(); - let mut force_normalize = false; + let mut cluster_style_index = u16::MAX; let mut is_emoji_or_pictograph = false; 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(); + cluster_style_index = *style_index; // 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(); - - char_cluster.chars.push(Char { - ch, - contributes_to_shaping: info.contributes_to_shaping(), - glyph_id: 0, - style_index: *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 = 0; + char_cluster.style_index = cluster_style_index; char_cluster.start = start; char_cluster.end = end; - char_cluster.force_normalize = force_normalize; } fn shape_item<'a, B: Brush>( @@ -277,7 +277,7 @@ fn shape_item<'a, B: Brush>( analysis_data_sources: &AnalysisDataSources, ) { let item_text = &text[text_range.clone()]; - let item_infos = &infos[char_range.start..char_range.end]; // Only process current item + let item_infos = &infos[char_range.start..char_range.end]; let first_style_index = item_infos[0].1; let fb_script = icu_convert::script_to_fontique(item.script, analysis_data_sources); let mut font_selector = FontSelector::new( @@ -294,7 +294,6 @@ fn shape_item<'a, B: Brush>( .segment_str(item_text); let mut item_infos_iter = item_infos.iter(); 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); @@ -307,16 +306,15 @@ fn shape_item<'a, B: Brush>( &item_text[last_boundary..current_boundary], &mut item_infos_iter, &mut code_unit_offset_in_string, - char_cluster, + &mut scx.char_cluster, ); - let mut current_font = - font_selector.select_font(char_cluster, analysis_data_sources, &mut scx.scratch_string); + let mut current_font = font_selector.select_font(&mut scx.char_cluster); - // Main segmentation loop (based on swash shape_clusters) - only within current item + // Main segmentation loop - segment by font changes while let Some(font) = current_font.take() { // Collect all clusters for this font segment - let cluster_range = char_cluster.range(); + let cluster_range = scx.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; @@ -328,20 +326,16 @@ fn shape_item<'a, B: Brush>( &item_text[last_boundary..current_boundary], &mut item_infos_iter, &mut code_unit_offset_in_string, - char_cluster, + &mut scx.char_cluster, ); - if let Some(next_font) = font_selector.select_font( - char_cluster, - analysis_data_sources, - &mut scx.scratch_string, - ) { + if let Some(next_font) = font_selector.select_font(&mut scx.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; + segment_end_offset = scx.char_cluster.range().end as usize - text_range.start; } } else { // No font determined, continue to next cluster @@ -349,133 +343,400 @@ fn shape_item<'a, B: Brush>( } } - // Shape this font segment with harfrust + // Shape this font segment 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(); + 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_infos = + &item_infos[segment_char_start..(segment_char_start + segment_char_count)]; - // 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), + // Shape with the selected font into ProcessedRun (without pushing to layout yet) + let segment_abs_text_range = + (text_range.start + segment_start_offset)..(text_range.start + segment_end_offset); + let mut processed_run = shape_to_processed_run( + rcx, + item, + scx, + layout, + &font, + fb_script, + segment_text, + segment_infos, + segment_abs_text_range.clone(), ); - 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)), - ) - }, + + // Analyze for holes and push, using fallback fonts for any holes + let mut tried_fonts = scx.tried_fonts_pool.acquire(); + let mut holes = scx.hole_pool.acquire(); + push_run_with_fallback( + rcx, + item, + scx, + layout, + &mut processed_run, + &font, + // Initial call uses primary font as current font + &font, + fb_script, + segment_text, + segment_infos, + segment_abs_text_range, + analysis_data_sources, + &mut font_selector, + &mut tried_fonts, + &mut holes, + true, // Reset tried_fonts for each sibling hole ); + scx.hole_pool.release(holes); + scx.tried_fonts_pool.release(tried_fonts); + } +} - let direction = if item.level & 1 != 0 { - harfrust::Direction::RightToLeft - } else { - harfrust::Direction::LeftToRight - }; - let hb_script = script_to_harfrust(fb_script); - let language = item - .locale - .as_ref() - .and_then(|lang| lang.language.as_str().parse::().ok()); - scx.features.clear(); - for feature in rcx.features(item.features).unwrap_or(&[]) { - scx.features.push(harfrust::Feature::new( - harfrust::Tag::from_u32(feature.tag), - feature.value as u32, - .., - )); +/// Analyze a ProcessedRun for holes and push it to layout, using fallback fonts for holes. +/// +/// This is the core logic shared between initial shaping and fallback shaping: +/// 1. Analyze the run for holes (glyph_id == 0) +/// 2. If no holes, push the entire run +/// 3. If holes, push shaped segments and recursively handle holes with fallback fonts +/// +/// reset_per_hole: +/// If true, reset tried_fonts for each hole (for sibling holes at top level). +/// If false, accumulate tried_fonts across sub-holes (for recursive calls). +#[allow(clippy::too_many_arguments)] +fn push_run_with_fallback<'a, B: Brush>( + rcx: &'a ResolveContext, + item: &Item, + scx: &mut ShapeContext, + layout: &mut Layout, + processed: &mut crate::layout::data::ProcessedRun, + primary_font: &SelectedFont, + current_font: &SelectedFont, + fb_script: fontique::Script, + segment_text: &str, + segment_infos: &[(CharInfo, u16)], + text_range: core::ops::Range, + analysis_data_sources: &AnalysisDataSources, + font_selector: &mut FontSelector<'a, '_, B>, + tried_fonts: &mut Vec, + holes: &mut Vec, + reset_per_hole: bool, +) { + use crate::layout::data::ShapedSegment; + + let analysis = crate::layout::data::LayoutData::::analyze_processed_run( + processed, + segment_text.len(), + holes, + ); + + if !analysis.has_holes { + // No holes - push the entire run + layout.data.push_processed_run(processed); + } else { + // Process each segment + for segment in analysis.segments { + match segment { + ShapedSegment::Shaped { + cluster_range, + text_range: seg_text_range, + } => { + // Push shaped portion + let abs_range = (text_range.start + seg_text_range.start) + ..(text_range.start + seg_text_range.end); + layout + .data + .push_processed_segment(processed, cluster_range, abs_range); + } + ShapedSegment::Hole(hole) => { + // Get hole text and infos + let hole_text = &segment_text[hole.text_range.clone()]; + let hole_infos = &segment_infos[hole.char_range.clone()]; + + if hole_text.is_empty() || hole_infos.is_empty() { + continue; + } + + let abs_hole_range = (text_range.start + hole.text_range.start) + ..(text_range.start + hole.text_range.end); + + // Reset tried_fonts for each sibling hole at top level + if reset_per_hole { + tried_fonts.clear(); + tried_fonts.push(current_font.clone()); + } + + // Configure font selector for this hole's style + let seg_boundaries = analysis_data_sources + .grapheme_segmenter() + .segment_str(hole_text); + let mut seg_iter = seg_boundaries.skip(1); + let first_seg_boundary = seg_iter.next().unwrap_or(hole_text.len()); + + let mut temp_infos_iter = hole_infos.iter(); + let mut temp_offset = abs_hole_range.start; + fill_cluster_in_place( + &hole_text[0..first_seg_boundary], + &mut temp_infos_iter, + &mut temp_offset, + &mut scx.char_cluster, + ); + + // Call select_font to configure query + let _ = font_selector.select_font(&mut scx.char_cluster); + + // Find next fallback font + let fallback = font_selector.select_next_font(tried_fonts); + + match fallback { + Some(fallback_font) => { + // Shape with fallback font + let mut fallback_processed = shape_to_processed_run( + rcx, + item, + scx, + layout, + &fallback_font, + fb_script, + hole_text, + hole_infos, + abs_hole_range.clone(), + ); + + tried_fonts.push(fallback_font.clone()); + + // Recursively process sub-holes + push_run_with_fallback( + rcx, + item, + scx, + layout, + &mut fallback_processed, + primary_font, + &fallback_font, + fb_script, + hole_text, + hole_infos, + abs_hole_range.clone(), + analysis_data_sources, + font_selector, + tried_fonts, + holes, + false, + ); + + // Return fallback vecs to pool + let (clusters, glyphs) = fallback_processed.take_vecs(); + scx.cluster_pool.release(clusters); + scx.glyph_pool.release(glyphs); + } + None => { + // No more fallbacks - shape with primary font for .notdef glyphs + let mut notdef_processed = shape_to_processed_run( + rcx, + item, + scx, + layout, + primary_font, + fb_script, + hole_text, + hole_infos, + abs_hole_range, + ); + layout.data.push_processed_run(¬def_processed); + let (clusters, glyphs) = notdef_processed.take_vecs(); + scx.cluster_pool.release(clusters); + scx.glyph_pool.release(glyphs); + } + } + } + } } + } + + // Return main run's vecs to pool + let (clusters, glyphs) = processed.take_vecs(); + scx.cluster_pool.release(clusters); + scx.glyph_pool.release(glyphs); +} + +/// Shape a text segment with a specific font into a ProcessedRun (without pushing to layout). +/// This allows analyzing for holes before deciding how to push. +#[allow(clippy::too_many_arguments)] +fn shape_to_processed_run( + rcx: &ResolveContext, + item: &Item, + scx: &mut ShapeContext, + layout: &mut Layout, + font: &SelectedFont, + fb_script: fontique::Script, + segment_text: &str, + segment_infos: &[(CharInfo, u16)], + text_range: core::ops::Range, +) -> crate::layout::data::ProcessedRun { + let font_ref = harfrust::FontRef::from_index(font.font.blob.as_ref(), font.font.index).unwrap(); + + let direction = if item.level & 1 != 0 { + harfrust::Direction::RightToLeft + } else { + harfrust::Direction::LeftToRight + }; + let hb_script = script_to_harfrust(fb_script); + let language = item + .locale + .as_ref() + .and_then(|lang| lang.language.as_str().parse::().ok()); + + // Acquire buffer from pool first (before cache borrows) + let mut buffer = std::mem::take(&mut scx.unicode_buffer).unwrap(); + buffer.clear(); + buffer.reserve(segment_text.len()); + + for (i, ch) in segment_text.chars().enumerate() { + buffer.add(ch, i as u32); + } + buffer.set_direction(direction); + buffer.set_script(hb_script); + if let Some(ref lang) = language { + buffer.set_language(lang.clone()); + } + + // Build features list + scx.features.clear(); + for feature in rcx.features(item.features).unwrap_or(&[]) { + scx.features.push(harfrust::Feature::new( + harfrust::Tag::from_u32(feature.tag), + feature.value as u32, + .., + )); + } + + // Build cache keys before borrowing caches + let data_key = cache::ShapeDataKey::new(font.font.blob.id(), font.font.index); + let instance_key = cache::ShapeInstanceKey::new( + font.font.blob.id(), + font.font.index, + &font.font.synthesis, + rcx.variations(item.variations), + ); + let plan_key = cache::ShapePlanKey::new( + font.font.blob.id(), + font.font.index, + &font.font.synthesis, + direction, + hb_script, + language.clone(), + &scx.features, + rcx.variations(item.variations), + ); + + // Access caches and shape + let (glyph_buffer, coords) = { + let shaper_data = scx + .shape_data_cache + .entry(data_key, || harfrust::ShaperData::new(&font_ref)); + let instance = scx.shape_instance_cache.entry(instance_key, || { + harfrust::ShaperInstance::from_variations( + &font_ref, + variations_iter(&font.font.synthesis, rcx.variations(item.variations)), + ) + }); + let harf_shaper = shaper_data .shaper(&font_ref) .instance(Some(instance)) .point_size(Some(item.size)) .build(); - let shaper_plan = scx.shape_plan_cache.entry( - cache::ShapePlanKey::new( - font.font.blob.id(), - font.font.index, - &font.font.synthesis, + + let shaper_plan = scx.shape_plan_cache.entry(plan_key, || { + harfrust::ShapePlan::new( + &harf_shaper, direction, - hb_script, - language.clone(), + Some(hb_script), + language.as_ref(), &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_with_plan(shaper_plan, buffer, &scx.features); + let coords: Vec<_> = harf_shaper.coords().to_vec(); + (glyph_buffer, coords) + }; - // 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_infos = - &item_infos[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.font.synthesis, - &glyph_buffer, - item.level, - item.style_index, - item.word_spacing, - item.letter_spacing, - segment_text, - segment_infos, - (text_range.start + segment_start_offset)..(text_range.start + segment_end_offset), - harf_shaper.coords(), + // Get cached metrics or compute them (no allocation for lookup) + let metrics_key = + cache::MetricsCacheKey::new(font.font.blob.id(), font.font.index, item.size, &coords); + let cached_metrics = scx.metrics_cache.entry(metrics_key, || { + let skrifa_font = skrifa::FontRef::from_index(font.font.blob.as_ref(), font.font.index) + .expect("failed to create skrifa font ref"); + // Convert coords to skrifa's expected format + let skrifa_coords: Vec = coords + .iter() + .map(|c| skrifa::instance::NormalizedCoord::from_bits(c.to_bits())) + .collect(); + let metrics = skrifa::metrics::Metrics::new( + &skrifa_font, + skrifa::prelude::Size::new(item.size), + skrifa_coords.as_slice(), ); + let units_per_em = metrics.units_per_em as f32; + let (underline_offset, underline_size) = if let Some(underline) = metrics.underline { + (underline.offset, underline.thickness) + } else { + let default = units_per_em / 18.0; + (default, default) + }; + let (strikethrough_offset, strikethrough_size) = if let Some(strikeout) = metrics.strikeout + { + (strikeout.offset, strikeout.thickness) + } else { + (metrics.ascent / 2.0, units_per_em / 18.0) + }; + cache::CachedMetrics { + units_per_em, + ascent: metrics.ascent, + descent: -metrics.descent, + leading: metrics.leading, + underline_offset, + underline_size, + strikethrough_offset, + strikethrough_size, + } + }); + + // Convert coords to i16 for storage (owned, passed directly to avoid clone) + let coords_i16: Vec = coords.iter().map(|c| c.to_bits()).collect(); + + // Acquire vectors from pools and reserve capacity + let mut clusters = scx.cluster_pool.acquire(); + clusters.reserve(segment_infos.len()); + let mut glyphs = scx.glyph_pool.acquire(); + // Estimate ~1.5 glyphs per character (handles ligatures, complex scripts) + glyphs.reserve(segment_infos.len() + segment_infos.len() / 2); + + // Process to temp storage (don't push yet) + let processed = layout.data.process_run_to_temp( + FontData::new(font.font.blob.clone(), font.font.index), + item.size, + font.font.synthesis.clone(), + &glyph_buffer, + item.level, + item.style_index, + item.word_spacing, + item.letter_spacing, + segment_text, + segment_infos, + text_range, + coords_i16, // Pass by ownership + cached_metrics, + clusters, + glyphs, + ); - // Replace buffer to reuse allocation in next iteration. - scx.unicode_buffer = Some(glyph_buffer.clear()); - } + // Return buffer to context + scx.unicode_buffer = Some(glyph_buffer.clear()); + + processed } fn real_script(script: Script) -> bool { @@ -551,12 +812,10 @@ impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> { } } - fn select_font( - &mut self, - cluster: &mut CharCluster, - analysis_data_sources: &AnalysisDataSources, - scratch_string: &mut String, - ) -> Option { + /// Select a font for the given cluster. + /// + /// Does not check character coverage. + fn select_font(&mut self, cluster: &mut CharCluster) -> Option { let style_index = cluster.style_index(); let is_emoji = cluster.is_emoji; if style_index != self.style_index || is_emoji || self.fonts_id.is_none() { @@ -588,51 +847,33 @@ impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> { self.variations = self.rcx.variations(style.font_variations).unwrap_or(&[]); self.features = self.rcx.features(style.font_features).unwrap_or(&[]); } + + // Just return the first matching font - no coverage checking. let mut selected_font = None; self.query.matches_with(|font| { - let Some(charmap) = font.charmap() else { - return fontique::QueryStatus::Continue; - }; - - let map_status = cluster.map( - |ch| { - charmap - .map(ch) - .map(|g| { - // HACK: in reality, we're only computing coverage, so - // we only care about whether the font has a mapping - // for a particular glyph. Any non-zero value indicates - // the existence of a glyph so we can simplify this - // without a fallible conversion from u32 to u16. - (g != 0) as u16 - }) - .unwrap_or_default() - }, - analysis_data_sources, - scratch_string, - ); + selected_font = Some(font.into()); + fontique::QueryStatus::Stop + }); + selected_font + } - match map_status { - Status::Complete => { - selected_font = Some(font.into()); - fontique::QueryStatus::Stop - } - Status::Keep => { - selected_font = Some(font.into()); - fontique::QueryStatus::Continue - } - Status::Discard => { - if selected_font.is_none() { - selected_font = Some(font.into()); - } - fontique::QueryStatus::Continue - } + /// Select the next font in the fallback chain, skipping any fonts we've already tried. + fn select_next_font(&mut self, skip_fonts: &[SelectedFont]) -> Option { + let mut selected_font = None; + self.query.matches_with(|font| { + let candidate: SelectedFont = font.into(); + if skip_fonts.iter().any(|skip| *skip == candidate) { + return fontique::QueryStatus::Continue; } + // Return the first font we haven't tried yet + selected_font = Some(candidate); + fontique::QueryStatus::Stop }); selected_font } } +#[derive(Clone)] struct SelectedFont { font: QueryFont, }