diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index a46c4d69c..5f6afc3b8 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -264,16 +264,7 @@ impl LayoutData { } /// Push an inline box to the list of items - pub(crate) fn push_inline_box(&mut self, index: usize) { - // Give the box the same bidi level as the preceding text run - // (or else default to 0 if there is not yet a text run) - let bidi_level = self - .shaped_text - .runs() - .last() - .map(|r| r.bidi_level) - .unwrap_or(BidiLevel::new(0)); - + pub(crate) fn push_inline_box(&mut self, index: usize, bidi_level: BidiLevel) { self.items.push(LayoutItem { kind: LayoutItemKind::InlineBox, index, diff --git a/parley/src/shape/mod.rs b/parley/src/shape/mod.rs index 7010a9e92..f0dfb7593 100644 --- a/parley/src/shape/mod.rs +++ b/parley/src/shape/mod.rs @@ -4,6 +4,7 @@ //! Text shaping implementation using `harfrust`for shaping //! and `icu` for text analysis. +use alloc::vec::Vec; use parley_engine::shape::{CharCluster, Coverage}; use parley_engine::{Analysis, AnalysisDataSources, FontInstance, ShapeOptions, Shaper}; use smallvec::SmallVec; @@ -14,10 +15,9 @@ use super::style::{Brush, FontFeature, FontVariation}; use crate::inline_box::InlineBox; use crate::util::{nearly_eq, nearly_zero}; use crate::{FontContext, FontData}; -use fontique::Language; use fontique::{self, Query, QueryFamily, QueryFont}; -use parlance::{GenericFamily, Script, Tag}; +use parlance::{BidiLevel, GenericFamily, Tag}; /// If these font features are passed to the shaper, optional ligatures are not applied. /// @@ -65,7 +65,7 @@ pub(crate) fn shape_text<'a, B: Brush>( // Process any remaining inline boxes whose index is greater than the length of the text for box_idx in 0..inline_boxes.len() { // Push the box to the list of items - layout.data.push_inline_box(box_idx); + layout.data.push_inline_box(box_idx, BidiLevel::new(0)); } return; } @@ -73,110 +73,174 @@ pub(crate) fn shape_text<'a, B: Brush>( let mut fq = fcx.collection.query(&mut fcx.source_cache); let mut inline_box_iter = inline_boxes.iter().peekable(); - let split_after = |item_range: parley_engine::itemize::TextRange| { - // Split at inlines boxes, so each box falls on a shaping boundary. - { - let mut split = false; - // We loop because there may be multiple boxes at this index. - while let Some(inline_box) = inline_box_iter.peek() { - if inline_box.index < item_range.byte_range.end { - // Inline boxes *before* this index are popped (this occurs if the itemizer - // split a run and we were not called, such as at a bidi boundary). - inline_box_iter.next(); - } else if inline_box.index == item_range.byte_range.end { - inline_box_iter.next(); - split = true; - } else { - break; - } - } - if split { - return true; + // Merge font features with letter-spacing ligature suppression. + // + // TODO: This allocation is slightly unfortunate (though solvable). It's required currently, + // because the iterator providing `ShapeOptions` has to provide a borrowed `&'a [FontFeature]`, + // which cannot be tied to the lifetime of the call to `Iterator::next`. What we'd need is a + // lending iterator (i.e., we probably just need to let `parley_engine` take some trait + // providing the items). + let style_features: &'_ Vec> = &styles + .iter() + .map(|style| { + let style_features = rcx.features(style.font_features).unwrap_or(&[]); + if !nearly_zero(style.letter_spacing) { + // Later values override earlier values. + OPTIONAL_LIGATURES_OFF + .into_iter() + .chain(style_features.iter().copied()) + .collect() + } else { + style_features.iter().copied().collect() + } + }) + .collect(); + + // Split when shaping-relevant style properties change and at inline boxes. + let items = { + // TODO: we currently walk characters here, but we could instead just walk boundaries of + // styles and inline boxes. + let char_count = char_style_indices.len(); + + // The first character of the item currently being built. + let mut item_start_char = 0_usize; + + // Positioned at `item_start_char`, i.e., the first character not yet covered by an item. + let mut chars = text.char_indices().enumerate().peekable(); + core::iter::from_fn(move || { + if item_start_char == char_count { + return None; } - } - - let item_style_index = char_style_indices[item_range.char_range.start]; - let style_index = char_style_indices[item_range.char_range.end]; - if style_index != item_style_index { + let item_style_index = char_style_indices[item_start_char]; let item_style = &styles[usize::from(item_style_index)]; - let style = &styles[usize::from(style_index)]; - !nearly_eq(style.font_size, item_style.font_size) - || style.locale != item_style.locale - || style.font_variations != item_style.font_variations - || style.font_features != item_style.font_features - || !nearly_eq(style.letter_spacing, item_style.letter_spacing) - || !nearly_eq(style.word_spacing, item_style.word_spacing) - } else { - false - } + + // Items are at least one character long, therefore the item's own first character is + // never a split point. + chars.next(); + + let char_end = loop { + let Some(&(char_index, (byte_index, _))) = chars.peek() else { + // End of text. + break char_count; + }; + + // Split at inlines boxes, so each box falls on a shaping boundary. + // + // We loop because there may be multiple boxes at this index. + let mut split = false; + while let Some(inline_box) = inline_box_iter.peek() { + if inline_box.index < byte_index { + // Inline boxes *before* this index are popped (this occurs if the itemizer + // split a run and we were not called, such as at a bidi boundary). + inline_box_iter.next(); + } else if inline_box.index == byte_index { + inline_box_iter.next(); + split = true; + } else { + break; + } + } + + if split { + break char_index; + } + + let style_index = char_style_indices[char_index]; + if style_index != item_style_index { + let style = &styles[usize::from(style_index)]; + split = !nearly_eq(style.font_size, item_style.font_size) + || style.locale != item_style.locale + || style.font_variations != item_style.font_variations + || style.font_features != item_style.font_features + || !nearly_eq(style.letter_spacing, item_style.letter_spacing) + || !nearly_eq(style.word_spacing, item_style.word_spacing); + } + + if split { + break char_index; + } + + chars.next(); + }; + + item_start_char = char_end; + Some(parley_engine::itemize::Item { + char_end: char_end as u32, + options: ShapeOptions { + language: item_style.locale, + font_size: item_style.font_size, + features: &style_features[item_style_index as usize], + variations: rcx.variations(item_style.font_variations).unwrap_or(&[]), + char_style_indices, + }, + }) + }) }; - let mut features_scratch = SmallVec::<[FontFeature; 8]>::new(); + let font_selector = FontSelector::new(&mut fq, rcx, styles, analysis_data_sources); + + scx.shape_text( + text, + analysis, + items, + font_selector, + &mut layout.data.shaped_text, + ); + let mut inline_box_iter = inline_boxes.iter().enumerate().peekable(); - for item in analysis.itemize(text, split_after) { + for shaped_run_idx in 0..layout.data.shaped_text.runs().len() { + let shaped_run = &layout.data.shaped_text.runs()[shaped_run_idx]; + let run_text_byte_start = shaped_run.range.byte_range.start; + let run_style_index = char_style_indices[shaped_run.range.char_range.start]; + let run_style = &styles[usize::from(run_style_index)]; + // Push inline boxes positioned before the start of this item. + // + // TODO: this lets the inline box take the bidi level of the previous run, but in principle + // inline boxes should be included in bidi analysis as an object replacement character + // (U+FFFC). The box should then take the bidi level of that character. + let prev_bidi_level = if shaped_run_idx > 0 { + layout.data.shaped_text.runs()[&shaped_run_idx - 1].bidi_level + } else { + BidiLevel::new(0) + }; while let Some((box_idx, inline_box)) = inline_box_iter.peek() { - if inline_box.index <= item.range.byte_range.start { - layout.data.push_inline_box(*box_idx); + if inline_box.index <= run_text_byte_start { + layout.data.push_inline_box(*box_idx, prev_bidi_level); inline_box_iter.next(); } else { break; } } - let style_index = char_style_indices[item.range.char_range.start]; - let style = &styles[usize::from(style_index)]; - let mut font_selector = - FontSelector::new(&mut fq, rcx, styles, style_index, item.script, style.locale); - - let style_features = rcx.features(style.font_features).unwrap_or(&[]); - let features = if !nearly_zero(style.letter_spacing) { - if style_features.is_empty() { - OPTIONAL_LIGATURES_OFF.as_slice() - } else { - features_scratch.clear(); - // Later values override earlier values. - features_scratch - .extend(OPTIONAL_LIGATURES_OFF.iter().chain(style_features).copied()); - features_scratch.as_slice() - } - } else { - style_features - }; - - let shaped_runs_range = scx.shape_item( - text, - analysis, - &item, - &ShapeOptions { - language: style.locale, - font_size: style.font_size, - features, - variations: rcx.variations(style.font_variations).unwrap_or(&[]), - char_style_indices, - }, - #[inline(always)] - |char_cluster| font_selector.select_font(char_cluster, analysis_data_sources), - &mut layout.data.shaped_text, + layout.data.process_shaped_run( + shaped_run_idx, + // TODO: should we get the *item's* style here? + // + // Using the run's style for word and letter spacing is probably correct (and in fact, + // we probably shouldn't itemize on them; we should just ensure we don't ligate if + // they're non-zero). + run_style, + run_style.word_spacing, + run_style.letter_spacing, ); - for shaped_run_idx in shaped_runs_range { - let shaped_run = &layout.data.shaped_text.runs()[shaped_run_idx]; - let run_style_index = char_style_indices[shaped_run.range.char_range.start]; - let run_style = &styles[usize::from(run_style_index)]; - layout.data.process_shaped_run( - shaped_run_idx, - run_style, - style.word_spacing, - style.letter_spacing, - ); - } } // Process any remaining inline boxes whose index is greater than the length of the text + // + // Give the box the same bidi level as the last text run (or else default to 0 if there is no + // text run). + let bidi_level = layout + .data + .shaped_text + .runs() + .last() + .map(|r| r.bidi_level) + .unwrap_or(BidiLevel::new(0)); for (box_idx, _inline_box) in inline_box_iter { - layout.data.push_inline_box(box_idx); + layout.data.push_inline_box(box_idx, bidi_level); } } @@ -200,53 +264,60 @@ struct FontSelector<'a, 'b, B: Brush> { /// The font to use if [`Self::query`] doesn't return any font. last_resort_font: LastResortFont, + + analysis_data_sources: &'a AnalysisDataSources, } impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> { /// Construct a new `FontSelector`. + /// + /// If `query` ends up not returning a font for a query, the `last_resort_font` is returned + /// instead. fn new( query: &'b mut Query<'a>, rcx: &'a ResolveContext, styles: &'a [ResolvedStyle], - style_index: u16, - script: Script, - locale: Option, + analysis_data_sources: &'a AnalysisDataSources, ) -> Self { - let style = &styles[style_index as usize]; - let fonts_id = style.font_family.id(); - let fonts = rcx.stack(style.font_family).unwrap_or(&[]); - let attrs = fontique::Attributes { - width: style.font_width, - weight: style.font_weight, - style: style.font_style, - }; - let variations = rcx.variations(style.font_variations).unwrap_or(&[]); - let features = rcx.features(style.font_features).unwrap_or(&[]); - query.set_families(fonts.iter().copied()); - - query.set_fallbacks(fontique::FallbackKey::new(script, locale.as_ref())); - query.set_attributes(attrs); + let attrs = fontique::Attributes::default(); Self { query, - fonts_id: Some(fonts_id), + fonts_id: None, rcx, styles, - style_index, + style_index: 0, attrs, - variations, - features, + variations: &[], + features: &[], last_resort_font: LastResortFont::Unresolved, + + analysis_data_sources, } } +} + +impl<'a, 'b, B: Brush> parley_engine::FontSelector for FontSelector<'a, 'b, B> { + fn begin_segment( + &mut self, + item: &parley_engine::itemize::Segment, + options: &ShapeOptions<'_>, + ) { + self.query.set_fallbacks(fontique::FallbackKey::new( + item.script, + options.language.as_ref(), + )); + } fn select_font( &mut self, + _item: &parley_engine::itemize::Segment, + _options: &ShapeOptions<'_>, cluster: &mut CharCluster, - analysis_data_sources: &AnalysisDataSources, ) -> 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() { self.style_index = style_index; let style = &self.styles[style_index as usize]; @@ -276,8 +347,10 @@ 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(&[]); } + let mut selected_font = None; let mut best_coverage = Coverage::NONE; + self.query.matches_with(|font| { let Some(charmap) = font.charmap() else { return fontique::QueryStatus::Continue; @@ -293,7 +366,7 @@ impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> { }) .unwrap_or_default() }, - analysis_data_sources, + self.analysis_data_sources, ); if coverage > best_coverage { selected_font = Some(SelectedFont { font: font.clone() }); diff --git a/parley_engine/src/itemize.rs b/parley_engine/src/itemize.rs index 6ebab63fd..1c51d6a65 100644 --- a/parley_engine/src/itemize.rs +++ b/parley_engine/src/itemize.rs @@ -8,7 +8,7 @@ use core::{ops::Range, str::CharIndices}; use icu_properties::props::Script as IcuScript; use parlance::{BidiLevel, Script}; -use crate::{Analysis, CharInfo}; +use crate::{Analysis, CharInfo, ShapeOptions}; /// A range of text. #[derive(Clone, Debug, PartialEq, Eq)] @@ -20,9 +20,9 @@ pub struct TextRange { pub char_range: Range, } -/// An item produced by [`Analysis::itemize`]. +/// A span of text inside an [`Item`] with constant script and bidirectional embedding level. #[derive(Clone, Debug)] -pub struct Item { +pub struct Segment { /// The text range of this item. pub range: TextRange, @@ -40,8 +40,46 @@ pub struct Item { pub script: Script, } -/// An iterator over items in text, produced by [`Analysis::itemize`]. -pub struct Itemizer<'a, F> { +/// A span of text shaped with specific [`ShapeOptions`]. +/// +/// An [`Item`] represents a sequence of constant [`ShapeOptions`], but cannot always be passed to +/// the shaper as a single unit. Within an item, the script or bidirectional text embedding level +/// may change, which requires further splitting the item into segments of constant script and bidi +/// level (see [`Segment`]). +#[derive(Debug)] +pub struct Item<'a> { + /// The character offset in the source text which this item ends. + /// + /// This must be strictly greater than the previous item's end. For the first item, it must be + /// greater than 0. + pub char_end: u32, + + /// The options to shape this item with. + // + // TODO: should users instead be allowed to build `options` at the `Segment` level (i.e., + // through some callback)? The motivation is for users to have access to the segment's `Script` + // and be able to set font features based on that. If the entirety of `ShapeOptions` moves + // there, it would allow users to set, e.g., a font size per segment, even though it's not + // necessarily an item boundary; and note an item then doesn't mean a whole lot anymore. We + // could also allow only some options to be per-segment. In any case, we're probably moving + // towards a future where items reset grapheme segmentation, but segments do not (note Gecko and + // Blink also reset grapheme segmentation when something like font size changes, but not when + // the script changes). + pub options: ShapeOptions<'a>, + // TODO: we probably should allow users to pass in some data (like we allow passing + // style_indices elsewhere), which we copy onto `ShapedRun`. That allows users to easily + // correlate `ShapedRun`s with some data they themselves hold. + // + // This is potentially important for better correctness in `parley`: it itemizes based on + // `nearly_eq` of shaping-relevant styles like font size, i.e., it should then read that item's + // style to know the font size, even though it may be different for the run. + // /// Opaque data copied onto every `ShapedRun` produced from this item. + // pub user_data: u16, +} + +/// Produces the items in a text via [`Self::next`]; created by [`Analysis::itemize`]. +#[derive(Debug)] +pub(crate) struct Itemizer<'a> { /// Our underlying iterator over the input text. char_indices: CharIndices<'a>, /// The per-char info, parallel to [`Self::char_indices`]. @@ -55,51 +93,23 @@ pub struct Itemizer<'a, F> { /// character. paragraph_bidi_level: BidiLevel, - /// User-provided itemization split predicate (e.g., if the font size changes). - split_after: F, - /// The running character offset of the last-processed item. current_char_offset: usize, /// The running script of the last-processed item. current_script: IcuScript, } -impl core::fmt::Debug for Itemizer<'_, F> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("Itemizer") - .field("char_indices", &self.char_indices) - .field("char_info", &self.char_info) - .field("bidi_levels", &self.bidi_levels) - .field("paragraph_bidi_level", &self.paragraph_bidi_level) - .field("current_char_offset", &self.current_char_offset) - .field("current_script", &self.current_script) - .finish_non_exhaustive() - } -} - impl Analysis { - /// Itemize the `text` into individually-shapeable runs. + /// Divide the `text` into individually-shapeable segments. /// /// The `text` passed in must be the same as used for producing the `self` analysis. /// - /// The text is itemized into items of constant bidi level and script. For consecutive - /// characters where the bidi level and script are unchanging, the `split_after` predicate is - /// called with the growing item range, and can be used to split on additional properties like - /// shaping-relevant style changes (e.g., font size) or properties like language. - /// - /// The predicate is given a range encoding the current item and considers whether to split - /// after that item based on the next character. Iff the predicate returns `true`, the text is - /// split after that item; i.e., given a range of `start..end`, the predicate controls whether - /// that item is now finished, or whether it is extended to include the character at `end` (at - /// which point the item spans `start..end+1`). + /// The text is divided into items produced by a predicate passed to [`Itemizer::next`] and + /// further divided into segments of constant bidi level and script. /// /// Characters that don't have a particular script have their script resolved based on - /// surrounding context (see [`Item::script`]). - pub fn itemize<'a, F: FnMut(TextRange) -> bool>( - &'a self, - text: &'a str, - split_after: F, - ) -> Itemizer<'a, F> { + /// surrounding context (see [`Segment::script`]). + pub(crate) fn itemize<'a>(&'a self, text: &'a str) -> Itemizer<'a> { let first_real_script = self .char_info() .iter() @@ -112,7 +122,6 @@ impl Analysis { char_info: self.char_info(), bidi_levels: self.bidi_levels(), paragraph_bidi_level: self.paragraph_level(), - split_after, current_char_offset: 0, current_script: first_real_script, @@ -120,10 +129,28 @@ impl Analysis { } } -impl bool> Iterator for Itemizer<'_, F> { - type Item = Item; - - fn next(&mut self) -> Option { +impl Itemizer<'_> { + /// Produce the next segment, if any. + /// + /// For consecutive characters where the bidi level and script are unchanging, the `split_after` + /// predicate is called with the growing item range, and can be used to split on additional + /// properties like shaping-relevant style changes (e.g., font size) or properties like + /// language. + /// + /// The predicate is given a range encoding the current item and considers whether to split + /// after that item based on the next character. Iff the predicate returns `true`, the text is + /// split after that item; i.e., given a range of `start..end`, the predicate controls whether + /// that item is now finished, or whether it is extended to include the character at `end` (at + /// which point the item spans `start..end+1`). + // + // TODO: currently the items from `split_after` have the same effect as a change in bidi or + // script. This is not how browsers handle things. In particular, `split_after` should reset + // grapheme segmentation, whereas bidi and script should produce separately-shaped segments. + #[inline] + pub(crate) fn next( + &mut self, + mut split_after: impl FnMut(TextRange) -> bool, + ) -> Option { if self.char_info.is_empty() { // We're already finished. debug_assert!( @@ -169,7 +196,7 @@ impl bool> Iterator for Itemizer<'_, F> { } if item_char_len > 0 - && (self.split_after)(TextRange { + && split_after(TextRange { byte_range: start_byte_offset..byte_offset, char_range: self.current_char_offset..self.current_char_offset + item_char_len, }) @@ -194,7 +221,7 @@ impl bool> Iterator for Itemizer<'_, F> { let start_char_offset = self.current_char_offset; self.current_char_offset += item_char_len; - Some(Item { + Some(Segment { range: TextRange { byte_range: start_byte_offset..self.char_indices.offset(), char_range: start_char_offset..self.current_char_offset, @@ -228,7 +255,7 @@ mod tests { use crate::{Analysis, AnalysisOptions, Analyzer}; - use super::Item; + use super::Segment; const LATN: Script = Script::from_bytes(*b"Latn"); const GREK: Script = Script::from_bytes(*b"Grek"); @@ -242,8 +269,10 @@ mod tests { analysis } - fn items(text: &str) -> Vec { - analyze(text).itemize(text, |_| false).collect() + fn items(text: &str) -> Vec { + let analysis = analyze(text); + let mut itemizer = analysis.itemize(text); + core::iter::from_fn(|| itemizer.next(|_| false)).collect() } #[test] @@ -277,9 +306,9 @@ mod tests { fn predicate() { let text = "abcdef"; let analysis = analyze(text); - let items: Vec<_> = analysis - .itemize(text, |range| range.char_range.end == 3) - .collect(); + let mut itemizer = analysis.itemize(text); + let items: Vec<_> = + core::iter::from_fn(|| itemizer.next(|range| range.char_range.end == 3)).collect(); assert_eq!(items.len(), 2); assert_eq!(items[0].range.byte_range, 0..3); assert_eq!(items[0].range.char_range, 0..3); diff --git a/parley_engine/src/lib.rs b/parley_engine/src/lib.rs index 154569b77..c2c3408da 100644 --- a/parley_engine/src/lib.rs +++ b/parley_engine/src/lib.rs @@ -40,4 +40,4 @@ pub use analyzer::{AnalysisOptions, Analyzer}; pub use glyph::Glyph; pub use shape::atom::{Atom, Atoms, Grapheme, Graphemes, ShapedSlice}; pub use shape::shaped_text::{FontMetrics, NormalizedCoord, ShapedRun, ShapedText}; -pub use shape::shaper::{FontInstance, ShapeOptions, Shaper}; +pub use shape::shaper::{FontInstance, FontSelector, ShapeOptions, Shaper}; diff --git a/parley_engine/src/shape/atom.rs b/parley_engine/src/shape/atom.rs index 1ce2a6072..1cb1b9d58 100644 --- a/parley_engine/src/shape/atom.rs +++ b/parley_engine/src/shape/atom.rs @@ -800,13 +800,29 @@ mod tests { use linebender_resource_handle::{Blob, FontData}; use crate::{ - Analysis, AnalysisOptions, Analyzer, FontInstance, ShapeOptions, ShapedText, Shaper, - itemize::Item, + Analysis, AnalysisOptions, Analyzer, FontInstance, FontSelector, ShapeOptions, ShapedText, + Shaper, + itemize::{Item, Segment}, + shape::CharCluster, }; const ROBOTO: &[u8] = include_bytes!("../../../parley_dev/assets/fonts/roboto_fonts/Roboto-Regular.ttf"); + /// A [`FontSelector`] shaping everything with a single font. + struct SingleFont(FontInstance); + + impl FontSelector for SingleFont { + fn select_font( + &mut self, + _segment: &Segment, + _options: &ShapeOptions<'_>, + _cluster: &mut CharCluster, + ) -> Option { + Some(self.0.clone()) + } + } + fn analyze(text: &str) -> Analysis { let mut analysis = Analysis::new(); Analyzer::new().analyze( @@ -828,39 +844,24 @@ mod tests { } } - fn shape_item_with_font( - text: &str, - analysis: &Analysis, - item: &Item, - font: &FontInstance, - shaper: &mut Shaper, - shaped: &mut ShapedText, - ) { + fn shape_with_font(text: &str, font_data: &'static [u8]) -> ShapedText { + let analysis = analyze(text); + let font = font_instance(font_data); + let mut shaper = Shaper::default(); + let mut shaped = ShapedText::new(); + let char_style_indices = vec![0; text.chars().count()]; - shaper.shape_item( - text, - analysis, - item, - &ShapeOptions { + let items = [Item { + char_end: text.chars().count().try_into().unwrap(), + options: ShapeOptions { font_size: 32.0, language: None, features: &[], variations: &[], char_style_indices: &char_style_indices, }, - |_| Some(font.clone()), - shaped, - ); - } - - fn shape_with_font(text: &str, font_data: &'static [u8]) -> ShapedText { - let analysis = analyze(text); - let font = font_instance(font_data); - let mut shaper = Shaper::default(); - let mut shaped = ShapedText::new(); - for item in analysis.itemize(text, |_| false) { - shape_item_with_font(text, &analysis, &item, &font, &mut shaper, &mut shaped); - } + }]; + shaper.shape_text(text, &analysis, items, SingleFont(font), &mut shaped); shaped } diff --git a/parley_engine/src/shape/shaped_text.rs b/parley_engine/src/shape/shaped_text.rs index 38bb4e94d..2ca283c61 100644 --- a/parley_engine/src/shape/shaped_text.rs +++ b/parley_engine/src/shape/shaped_text.rs @@ -9,15 +9,13 @@ use alloc::vec::Vec; use parlance::BidiLevel; use crate::{ - CharInfo, FontInstance, Glyph, ShapeOptions, - itemize::{Item, TextRange}, - shape::ShapedClusterFlags, + CharInfo, FontInstance, Glyph, + itemize::{Segment, TextRange}, }; use super::{ - ClusterInfo, Whitespace, - atom::ShapedSlice, - data::{Character, ShapedCluster}, + Character, ClusterInfo, ShapedCluster, ShapedClusterFlags, Whitespace, atom::ShapedSlice, + shaper::ShapeOptions, }; /// A normalized font coordinate. @@ -89,9 +87,8 @@ pub struct FontMetrics { /// The result of shaping. /// -/// After [itemizing][crate::itemize::Item] your text, -/// [shape each item][crate::Shaper::shape_item], appending the result into this -/// [`ShapedText`]. This then holds your shaped paragraph of text. +/// After [analyzing][crate::Analysis] your text, [shape the text][crate::Shaper::shape_text], +/// writing the result into this [`ShapedText`]. This then holds your shaped paragraph of text. /// /// This shaped text holds spans of the source text's characters that were shaped into /// [`ShapedCluster`]s. Note that the boundaries of shaped clusters and graphemes need not coincide; @@ -236,7 +233,7 @@ impl ShapedText { &mut self, text: &str, range: TextRange, - item: &Item, + item: &Segment, options: &ShapeOptions<'_>, char_info: &[CharInfo], font: &FontInstance, @@ -562,7 +559,11 @@ mod tests { use fontique::Synthesis; use linebender_resource_handle::{Blob, FontData}; - use crate::{Analysis, AnalysisOptions, Analyzer, FontInstance, ShapeOptions, Shaper}; + use crate::{ + Analysis, AnalysisOptions, Analyzer, FontInstance, FontSelector, ShapeOptions, Shaper, + itemize::{Item, Segment}, + shape::CharCluster, + }; use super::ShapedText; @@ -571,6 +572,20 @@ mod tests { const NOTO_KUFI_ARABIC: &[u8] = include_bytes!("../../../parley_dev/assets/fonts/noto_fonts/NotoKufiArabic-Regular.otf"); + /// A [`FontSelector`] shaping everything with a single font. + struct SingleFont(FontInstance); + + impl FontSelector for SingleFont { + fn select_font( + &mut self, + _item: &Segment, + _options: &ShapeOptions<'_>, + _cluster: &mut CharCluster, + ) -> Option { + Some(self.0.clone()) + } + } + fn analyze(text: &str) -> Analysis { let mut analysis = Analysis::new(); Analyzer::new().analyze( @@ -592,39 +607,24 @@ mod tests { } } - fn shape_item_with_font( - text: &str, - analysis: &Analysis, - item: &crate::itemize::Item, - font: &FontInstance, - shaper: &mut Shaper, - shaped: &mut ShapedText, - ) { + fn shape_with_font(text: &str, font_data: &'static [u8]) -> ShapedText { + let analysis = analyze(text); + let font = font_instance(font_data); + let mut shaper = Shaper::default(); + let mut shaped = ShapedText::new(); + let char_style_indices = vec![0; text.chars().count()]; - shaper.shape_item( - text, - analysis, - item, - &ShapeOptions { + let items = [Item { + char_end: text.chars().count().try_into().unwrap(), + options: ShapeOptions { font_size: 32.0, language: None, features: &[], variations: &[], char_style_indices: &char_style_indices, }, - |_| Some(font.clone()), - shaped, - ); - } - - fn shape_with_font(text: &str, font_data: &'static [u8]) -> ShapedText { - let analysis = analyze(text); - let font = font_instance(font_data); - let mut shaper = Shaper::default(); - let mut shaped = ShapedText::new(); - for item in analysis.itemize(text, |_| false) { - shape_item_with_font(text, &analysis, &item, &font, &mut shaper, &mut shaped); - } + }]; + shaper.shape_text(text, &analysis, items, SingleFont(font), &mut shaped); shaped } @@ -708,6 +708,7 @@ mod tests { fn item_boundaries_force_grapheme_start() { // Two regional indicators forming a single flag grapheme... let text = "\u{1F1E6}\u{1F1E7}"; + let char_style_indices = vec![0; text.chars().count()]; let analysis = analyze(text); assert_eq!( analysis @@ -718,18 +719,34 @@ mod tests { [true, false] ); - // ...split over two items. - let items: Vec<_> = analysis - .itemize(text, |range| range.char_range.end == 1) - .collect(); - assert_eq!(items.len(), 2); - let font = font_instance(ROBOTO); let mut shaper = Shaper::default(); let mut shaped = ShapedText::new(); - for item in &items { - shape_item_with_font(text, &analysis, item, &font, &mut shaper, &mut shaped); - } + + // ...split over two items. + let items = [ + Item { + char_end: 1, + options: ShapeOptions { + font_size: 32.0, + language: None, + features: &[], + variations: &[], + char_style_indices: &char_style_indices, + }, + }, + Item { + char_end: text.chars().count().try_into().unwrap(), + options: ShapeOptions { + font_size: 32.0, + language: None, + features: &[], + variations: &[], + char_style_indices: &char_style_indices, + }, + }, + ]; + shaper.shape_text(text, &analysis, items, SingleFont(font), &mut shaped); let grapheme_starts: Vec = shaped.characters.iter().map(|c| c.grapheme_start).collect(); diff --git a/parley_engine/src/shape/shaper.rs b/parley_engine/src/shape/shaper.rs index 079113f33..76ab1e7b2 100644 --- a/parley_engine/src/shape/shaper.rs +++ b/parley_engine/src/shape/shaper.rs @@ -4,14 +4,14 @@ //! Shaping of text. use alloc::vec::Vec; -use core::{mem, ops::Range}; +use core::mem; use harfrust::ShapeOptions as HarfShapeOptions; use linebender_resource_handle::FontData; use parlance::{FontFeature, FontVariation, Language}; use crate::{ Analysis, CharInfo, ShapedText, - itemize::{Item, TextRange}, + itemize::{Item, Segment, TextRange}, lru_cache::LruCache, shape::{CharCluster, cache}, }; @@ -19,8 +19,8 @@ use crate::{ /// 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. +/// from the underlying text. When you [shape the text][`Shaper::shape_text`], you should split the +/// text into [`Item`]s at the points where these options change. #[derive(Debug)] pub struct ShapeOptions<'a> { /// The font size to shape the item with. @@ -36,6 +36,8 @@ pub struct ShapeOptions<'a> { 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). + // TODO: probably move this out of `ShapeOptions`, and supply it as a parameter on + // `Shaper::shape_text`. pub char_style_indices: &'a [u16], } @@ -50,7 +52,7 @@ pub struct FontInstance { pub synthesis: fontique::Synthesis, } -/// Reusable scratch to shape [items][`Item`] into shaped text using [`Self::shape_item`]. +/// Reusable scratch to shape text using [`Self::shape_text`]. pub struct Shaper { shape_data_cache: LruCache, shape_instance_cache: LruCache, @@ -81,62 +83,109 @@ impl core::fmt::Debug for Shaper { } impl Shaper { - /// Shape an [`Item`] produced by [`Analysis::itemize`] into glyphs. + /// Shape text into glyphs, overwriting `shaped_text`. /// - /// The item is broken into runs of maximal sequences of character clusters for which - /// `select_font` returns the same font. The resulting shaped runs are appended to - /// `shaped_text`. + /// The `text` passed in must be the same as used for producing `analysis`. /// - /// `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. + /// This uses the items returned by `items`, and further itemizes the text into + /// individually-shapeable segments of constant bidi level and script. `items` should be used to + /// split on properties like shaping-relevant style changes (e.g., font size) or properties like + /// language. /// - /// 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. - /// Shaping is aborted if `select_font` returns `None`; [`ShapedText`] then contains a partial - /// result. + /// Characters that don't have a particular script have their script resolved based on + /// surrounding context (see [`Segment::script`]). /// - /// Returns the index range of runs appended to `shaped_text`. + /// Each segment is then broken into runs of maximal sequences of character clusters for which + /// `select_font` returns the same font. /// /// # Panics /// - /// Panics if the font returned by `select_font` isn't a parseable font. - pub fn shape_item( + /// Panics if `items` does not cover the entire source text, or the font returned by + /// `select_font` is malformed. + pub fn shape_text<'options>( &mut self, text: &str, analysis: &Analysis, - item: &Item, - options: &ShapeOptions<'_>, - select_font: impl FnMut(&mut CharCluster) -> Option, + items: impl IntoIterator>, + mut select_font: impl FontSelector, shaped_text: &mut ShapedText, - ) -> Range { - shaped_text.reserve(item.range.char_range.len()); + ) { + shaped_text.clear(); + shaped_text.reserve(text.len()); + + let char_count = analysis.char_info().len(); + let mut previous_item_end = 0; + let mut itemizer = analysis.itemize(text); + + for item in items { + assert!( + item.char_end > previous_item_end, + "item ends must be strictly increasing" + ); + assert!( + item.char_end as usize <= char_count, + "items must not span past the text" + ); - let start = shaped_text.runs().len(); - let _ = shape_item( - self, - text, - item, - options, - select_font, - analysis.char_info(), - shaped_text, + loop { + let segment = itemizer + .next( + #[inline(always)] + |text_range| text_range.char_range.end == item.char_end as usize, + ) + .expect("A segment must be yielded, given items tile the full text exactly"); + + if shape_segment( + self, + text, + &segment, + &item.options, + &mut select_font, + analysis.char_info(), + shaped_text, + ) + .is_err() + { + // Abort on error. This happens iff `FontSelector::select_font` failed to return a + // font. By aborting we ensure `ShapedText` covers the source text contiguously (as + // we need a font to construct `ShapedRun`). + return; + }; + + debug_assert!( + segment.range.char_range.end <= item.char_end as usize, + "Segments must not span past the item." + ); + if segment.range.char_range.end == item.char_end as usize { + break; + } + } + + previous_item_end = item.char_end; + } + + assert_eq!( + previous_item_end as usize, char_count, + "`items` does not cover the entire source text" ); - start..shaped_text.runs().len() } } -/// Shape one item. +/// Shape one segment. /// -/// Returns `Err(())` if shaping should be aborted, which happens iff `select_font` returned `None`. -fn shape_item( +/// Returns `Err(())` if shaping should be aborted, which happens iff [`FontSelector::select_font`] +/// returned `None`. +fn shape_segment( scx: &mut Shaper, text: &str, - item: &Item, + item: &Segment, options: &ShapeOptions<'_>, - mut select_font: impl FnMut(&mut CharCluster) -> Option, + select_font: &mut impl FontSelector, char_info: &[CharInfo], shaped_text: &mut ShapedText, ) -> Result<(), ()> { + select_font.begin_segment(item, options); + let text_range = &item.range.byte_range; let char_range = &item.range.char_range; @@ -173,7 +222,7 @@ fn shape_item( &mut code_unit_offset_in_string, ); - let Some(next_font) = select_font(char_cluster) else { + let Some(next_font) = select_font.select_font(item, options, char_cluster) else { return Err(()); }; let mut current_font = Some(next_font); @@ -195,7 +244,7 @@ fn shape_item( &mut code_unit_offset_in_string, ); - let Some(next_font) = select_font(char_cluster) else { + let Some(next_font) = select_font.select_font(item, options, char_cluster) else { return Err(()); }; if next_font != font { @@ -363,3 +412,26 @@ 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) } + +/// Implements font selection for shaping. +pub trait FontSelector { + /// Called when a new item starts. + /// + /// This can be useful to inspect, e.g., the item's script. + fn begin_segment(&mut self, item: &Segment, options: &ShapeOptions<'_>) { + let _ = (item, options); + } + + /// Called once per character cluster within the current segment. + /// + /// A character cluster will usually be a grapheme, though if text direction or script changes + /// mid-grapheme, it will be split over segments. + /// + /// Shaping is aborted if this returns `None`; [`ShapedText`] then contains a partial result. + fn select_font( + &mut self, + item: &Segment, + options: &ShapeOptions<'_>, + cluster: &mut CharCluster, + ) -> Option; +}