From dd5fcda8aafe202e9fbaa08471b414628099caa5 Mon Sep 17 00:00:00 2001 From: thekingofcity <3353040+thekingofcity@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:40:48 +0800 Subject: [PATCH 1/4] prefer font-family during fallback --- crates/usvg/src/text/layout.rs | 55 ++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs index 448d6545d..2f0a2d6e3 100644 --- a/crates/usvg/src/text/layout.rs +++ b/crates/usvg/src/text/layout.rs @@ -907,6 +907,7 @@ fn process_chunk( let tmp_glyphs = shape_text( &chunk.text, + &span.font, font, span.small_caps, span.apply_kerning, @@ -1292,6 +1293,7 @@ impl DatabaseExt for Database { /// Text shaping with font fallback. pub(crate) fn shape_text( text: &str, + font_node: &crate::Font, font: Arc, small_caps: bool, apply_kerning: bool, @@ -1327,9 +1329,56 @@ pub(crate) fn shape_text( } if let Some(c) = missing { - let fallback_font = match (resolver.select_fallback)(c, &used_fonts, fontdb) - .and_then(|id| fontdb.load_font(id)) - { + let mut fallback_id = None; + + let stretch = match font_node.stretch { + crate::FontStretch::UltraCondensed => fontdb::Stretch::UltraCondensed, + crate::FontStretch::ExtraCondensed => fontdb::Stretch::ExtraCondensed, + crate::FontStretch::Condensed => fontdb::Stretch::Condensed, + crate::FontStretch::SemiCondensed => fontdb::Stretch::SemiCondensed, + crate::FontStretch::Normal => fontdb::Stretch::Normal, + crate::FontStretch::SemiExpanded => fontdb::Stretch::SemiExpanded, + crate::FontStretch::Expanded => fontdb::Stretch::Expanded, + crate::FontStretch::ExtraExpanded => fontdb::Stretch::ExtraExpanded, + crate::FontStretch::UltraExpanded => fontdb::Stretch::UltraExpanded, + }; + + let style = match font_node.style { + crate::FontStyle::Normal => fontdb::Style::Normal, + crate::FontStyle::Italic => fontdb::Style::Italic, + crate::FontStyle::Oblique => fontdb::Style::Oblique, + }; + + for family in &font_node.families { + let fontdb_family = match family { + crate::FontFamily::Serif => fontdb::Family::Serif, + crate::FontFamily::SansSerif => fontdb::Family::SansSerif, + crate::FontFamily::Cursive => fontdb::Family::Cursive, + crate::FontFamily::Fantasy => fontdb::Family::Fantasy, + crate::FontFamily::Monospace => fontdb::Family::Monospace, + crate::FontFamily::Named(s) => fontdb::Family::Name(s), + }; + + let query = fontdb::Query { + families: &[fontdb_family], + weight: fontdb::Weight(font_node.weight), + stretch, + style, + }; + + if let Some(id) = fontdb.query(&query) { + if !used_fonts.contains(&id) && fontdb.has_char(id, c) { + fallback_id = Some(id); + break; + } + } + } + + let fallback_id = fallback_id.or_else(|| { + (resolver.select_fallback)(c, &used_fonts, fontdb) + }); + + let fallback_font = match fallback_id.and_then(|id| fontdb.load_font(id)) { Some(v) => Arc::new(v), None => break 'outer, }; From cb70270c2a5e9fded8cdbd97a05faa1c1249f1da Mon Sep 17 00:00:00 2001 From: thekingofcity <3353040+thekingofcity@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:24:03 +0800 Subject: [PATCH 2/4] avoid replacing the whole text run with fallback glyphs --- crates/usvg/src/text/layout.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs index 2f0a2d6e3..42fde3f21 100644 --- a/crates/usvg/src/text/layout.rs +++ b/crates/usvg/src/text/layout.rs @@ -1396,13 +1396,6 @@ pub(crate) fn shape_text( ) .unwrap_or_default(); - let all_matched = fallback_glyphs.iter().all(|g| !g.is_missing()); - if all_matched { - // Replace all glyphs when all of them were matched. - glyphs = fallback_glyphs; - break 'outer; - } - // We assume, that shaping with an any font will produce the same amount of glyphs. // This is incorrect, but good enough for now. if glyphs.len() != fallback_glyphs.len() { From 527768cd48216bed4aa5b6e203b4d3c42e487467 Mon Sep 17 00:00:00 2001 From: thekingofcity <3353040+thekingofcity@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:26:40 +0800 Subject: [PATCH 3/4] Improve font-family-aware glyph fallback --- crates/usvg/src/parser/mod.rs | 4 +- crates/usvg/src/text/layout.rs | 94 ++++++++---------- crates/usvg/src/text/mod.rs | 168 +++++++++++++++++++++++++-------- crates/usvg/tests/parser.rs | 85 +++++++++++++++++ 4 files changed, 254 insertions(+), 97 deletions(-) diff --git a/crates/usvg/src/parser/mod.rs b/crates/usvg/src/parser/mod.rs index b3fbccdd6..f1a54e5f7 100644 --- a/crates/usvg/src/parser/mod.rs +++ b/crates/usvg/src/parser/mod.rs @@ -132,8 +132,8 @@ impl crate::Tree { #[cfg(feature = "text")] font_resolver: crate::FontResolver { select_font: Box::new(|font, db| (opt.font_resolver.select_font)(font, db)), - select_fallback: Box::new(|c, used_fonts, db| { - (opt.font_resolver.select_fallback)(c, used_fonts, db) + select_fallback: Box::new(|request, db| { + (opt.font_resolver.select_fallback)(request, db) }), }, ..Options::default() diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs index 42fde3f21..dffcca46b 100644 --- a/crates/usvg/src/text/layout.rs +++ b/crates/usvg/src/text/layout.rs @@ -15,9 +15,9 @@ use unicode_script::UnicodeScript; use crate::tree::{BBox, IsValidLength}; use crate::{ - AlignmentBaseline, ApproxZeroUlps, BaselineShift, DominantBaseline, Fill, FillRule, Font, - FontResolver, LengthAdjust, PaintOrder, Path, ShapeRendering, Stroke, Text, TextAnchor, - TextChunk, TextDecorationStyle, TextFlow, TextPath, TextSpan, WritingMode, + AlignmentBaseline, ApproxZeroUlps, BaselineShift, DominantBaseline, FallbackRequest, Fill, + FillRule, Font, FontResolver, LengthAdjust, PaintOrder, Path, ShapeRendering, Stroke, Text, + TextAnchor, TextChunk, TextDecorationStyle, TextFlow, TextPath, TextSpan, WritingMode, }; /// A glyph that has already been positioned correctly. @@ -254,7 +254,7 @@ pub(crate) fn layout_text( } for span in &chunk.spans { - let font = match fonts_cache.get(&span.font) { + let font = match span_font(span, &clusters, &fonts_cache, fontdb.as_ref()) { Some(v) => v, None => continue, }; @@ -264,7 +264,7 @@ pub(crate) fn layout_text( let mut span_ts = text_ts; span_ts = span_ts.pre_translate(x, y); if let TextFlow::Linear = chunk.text_flow { - let shift = resolve_baseline(span, font, text_node.writing_mode); + let shift = resolve_baseline(span, &font, text_node.writing_mode); // In case of a horizontal flow, shift transform and not clusters, // because clusters can be rotated and an additional shift will lead @@ -287,7 +287,7 @@ pub(crate) fn layout_text( }; if let Some(path) = - convert_decoration(offset, span, font, decoration, &decoration_spans, span_ts) + convert_decoration(offset, span, &font, decoration, &decoration_spans, span_ts) { bbox = bbox.expand(path.data.bounds()); underline = Some(path); @@ -301,7 +301,7 @@ pub(crate) fn layout_text( }; if let Some(path) = - convert_decoration(offset, span, font, decoration, &decoration_spans, span_ts) + convert_decoration(offset, span, &font, decoration, &decoration_spans, span_ts) { bbox = bbox.expand(path.data.bounds()); overline = Some(path); @@ -315,7 +315,7 @@ pub(crate) fn layout_text( }; if let Some(path) = - convert_decoration(offset, span, font, decoration, &decoration_spans, span_ts) + convert_decoration(offset, span, &font, decoration, &decoration_spans, span_ts) { bbox = bbox.expand(path.data.bounds()); line_through = Some(path); @@ -417,6 +417,28 @@ fn convert_span( Some((span_clusters, bbox)) } +fn span_font( + span: &TextSpan, + clusters: &[GlyphCluster], + fonts_cache: &FontsCache, + fontdb: &fontdb::Database, +) -> Option> { + if let Some(font) = fonts_cache.get(&span.font) { + return Some(font.clone()); + } + + // A span can still have drawable glyphs when its declared font cannot be + // resolved, because another shaping pass may already have filled them via + // glyph fallback. In that case, use the first rendered glyph's font for + // span-level metrics such as baseline and decorations. + clusters + .iter() + .find(|cluster| span_contains(span, cluster.byte_idx)) + .and_then(|cluster| cluster.glyphs.first()) + .and_then(|glyph| fontdb.load_font(glyph.font)) + .map(Arc::new) +} + fn collect_decoration_spans(span: &TextSpan, clusters: &[GlyphCluster]) -> Vec { let mut spans = Vec::new(); @@ -1329,54 +1351,14 @@ pub(crate) fn shape_text( } if let Some(c) = missing { - let mut fallback_id = None; - - let stretch = match font_node.stretch { - crate::FontStretch::UltraCondensed => fontdb::Stretch::UltraCondensed, - crate::FontStretch::ExtraCondensed => fontdb::Stretch::ExtraCondensed, - crate::FontStretch::Condensed => fontdb::Stretch::Condensed, - crate::FontStretch::SemiCondensed => fontdb::Stretch::SemiCondensed, - crate::FontStretch::Normal => fontdb::Stretch::Normal, - crate::FontStretch::SemiExpanded => fontdb::Stretch::SemiExpanded, - crate::FontStretch::Expanded => fontdb::Stretch::Expanded, - crate::FontStretch::ExtraExpanded => fontdb::Stretch::ExtraExpanded, - crate::FontStretch::UltraExpanded => fontdb::Stretch::UltraExpanded, - }; - - let style = match font_node.style { - crate::FontStyle::Normal => fontdb::Style::Normal, - crate::FontStyle::Italic => fontdb::Style::Italic, - crate::FontStyle::Oblique => fontdb::Style::Oblique, - }; - - for family in &font_node.families { - let fontdb_family = match family { - crate::FontFamily::Serif => fontdb::Family::Serif, - crate::FontFamily::SansSerif => fontdb::Family::SansSerif, - crate::FontFamily::Cursive => fontdb::Family::Cursive, - crate::FontFamily::Fantasy => fontdb::Family::Fantasy, - crate::FontFamily::Monospace => fontdb::Family::Monospace, - crate::FontFamily::Named(s) => fontdb::Family::Name(s), - }; - - let query = fontdb::Query { - families: &[fontdb_family], - weight: fontdb::Weight(font_node.weight), - stretch, - style, - }; - - if let Some(id) = fontdb.query(&query) { - if !used_fonts.contains(&id) && fontdb.has_char(id, c) { - fallback_id = Some(id); - break; - } - } - } - - let fallback_id = fallback_id.or_else(|| { - (resolver.select_fallback)(c, &used_fonts, fontdb) - }); + let fallback_id = (resolver.select_fallback)( + FallbackRequest { + character: c, + font: font_node, + exclude_fonts: &used_fonts, + }, + fontdb, + ); let fallback_font = match fallback_id.and_then(|id| fontdb.load_font(id)) { Some(v) => Arc::new(v), diff --git a/crates/usvg/src/text/mod.rs b/crates/usvg/src/text/mod.rs index 4b48274e1..b845a95f7 100644 --- a/crates/usvg/src/text/mod.rs +++ b/crates/usvg/src/text/mod.rs @@ -7,7 +7,7 @@ use fontdb::{Database, ID}; use svgtypes::FontFamily; use self::layout::DatabaseExt; -use crate::{Cache, Font, FontStretch, FontStyle, Text}; +use crate::{Cache, Font, Text}; pub(crate) mod flatten; @@ -36,19 +36,49 @@ pub mod layout; pub type FontSelectionFn<'a> = Box) -> Option + Send + Sync + 'a>; +/// A fallback font selection request. +#[derive(Clone, Copy, Debug)] +pub struct FallbackRequest<'a> { + /// The character that needs a fallback font. + pub character: char, + /// The font specification of the current text span. + /// + /// This is provided as context for missing-glyph fallback selection. It + /// does not imply that the resolver controls text segmentation or the full + /// `font-family` cascade for the text run. + pub font: &'a Font, + /// Fonts that have already been used while shaping the current run. + pub exclude_fonts: &'a [ID], +} + /// A shorthand for [FontResolver]'s fallback selection function. /// -/// This function receives a specific character, a list of already used fonts, -/// and a font database. It should return the ID of a font that +/// This function receives a fallback request and a font database. It should +/// return the ID of a font that /// - is not any of the already used fonts /// - is as close as possible to the first already used font (if any) /// - supports the given character /// +/// The resolver is only responsible for selecting a candidate font for a +/// missing glyph. It does not control text segmentation, shaping, or a full +/// browser-style `font-family` cascade for the whole text run. +/// /// The function can search the existing database, but can also load additional /// fonts dynamically. See the documentation of [`FontSelectionFn`] for more /// details. pub type FallbackSelectionFn<'a> = - Box) -> Option + Send + Sync + 'a>; + Box, &mut Arc) -> Option + Send + Sync + 'a>; + +fn fontdb_family(family: &FontFamily) -> fontdb::Family<'_> { + match family { + FontFamily::Serif => fontdb::Family::Serif, + FontFamily::SansSerif => fontdb::Family::SansSerif, + FontFamily::Cursive => fontdb::Family::Cursive, + FontFamily::Fantasy => fontdb::Family::Fantasy, + FontFamily::Monospace => fontdb::Family::Monospace, + FontFamily::Named(s) => fontdb::Family::Name(s), + } +} /// A font resolver for `` elements. /// @@ -63,7 +93,11 @@ pub struct FontResolver<'a> { pub select_font: FontSelectionFn<'a>, /// Resolver function that will be used when selecting a fallback font for a - /// character. + /// missing character. + /// + /// This callback only selects fallback font candidates. It does not control + /// how text is split into runs or how shaping results are merged back into + /// the laid out text. pub select_fallback: FallbackSelectionFn<'a>, } @@ -86,42 +120,17 @@ impl FontResolver<'_> { Box::new(move |font, fontdb| { let mut name_list = Vec::new(); for family in &font.families { - name_list.push(match family { - FontFamily::Serif => fontdb::Family::Serif, - FontFamily::SansSerif => fontdb::Family::SansSerif, - FontFamily::Cursive => fontdb::Family::Cursive, - FontFamily::Fantasy => fontdb::Family::Fantasy, - FontFamily::Monospace => fontdb::Family::Monospace, - FontFamily::Named(s) => fontdb::Family::Name(s), - }); + name_list.push(fontdb_family(family)); } // Use the default font as fallback. name_list.push(fontdb::Family::Serif); - let stretch = match font.stretch { - FontStretch::UltraCondensed => fontdb::Stretch::UltraCondensed, - FontStretch::ExtraCondensed => fontdb::Stretch::ExtraCondensed, - FontStretch::Condensed => fontdb::Stretch::Condensed, - FontStretch::SemiCondensed => fontdb::Stretch::SemiCondensed, - FontStretch::Normal => fontdb::Stretch::Normal, - FontStretch::SemiExpanded => fontdb::Stretch::SemiExpanded, - FontStretch::Expanded => fontdb::Stretch::Expanded, - FontStretch::ExtraExpanded => fontdb::Stretch::ExtraExpanded, - FontStretch::UltraExpanded => fontdb::Stretch::UltraExpanded, - }; - - let style = match font.style { - FontStyle::Normal => fontdb::Style::Normal, - FontStyle::Italic => fontdb::Style::Italic, - FontStyle::Oblique => fontdb::Style::Oblique, - }; - let query = fontdb::Query { families: &name_list, weight: fontdb::Weight(font.weight), - stretch, - style, + stretch: font.stretch.into(), + style: font.style.into(), }; let id = fontdb.query(&query); @@ -142,21 +151,45 @@ impl FontResolver<'_> { /// Creates a default font fallback selection resolver. /// - /// The default implementation searches through the entire `fontdb` + /// The default implementation first prefers fonts from the declared + /// `font-family` list and then searches through the entire `fontdb` /// to find a font that has the correct style and supports the character. + /// This still operates as missing-glyph fallback, not as a full text-run + /// segmentation strategy. pub fn default_fallback_selector() -> FallbackSelectionFn<'static> { - Box::new(|c, exclude_fonts, fontdb| { - let base_font_id = exclude_fonts[0]; + Box::new(|request, fontdb| { + let Some(&base_font_id) = request.exclude_fonts.first() else { + return None; + }; + + for family in request.font.families() { + let family = fontdb_family(family); + let query = fontdb::Query { + families: &[family], + weight: fontdb::Weight(request.font.weight()), + stretch: request.font.stretch().into(), + style: request.font.style().into(), + }; + + if let Some(id) = fontdb.query(&query) { + if !request.exclude_fonts.contains(&id) + && fontdb.has_char(id, request.character) + { + return Some(id); + } + } + } + + let base_face = fontdb.face(base_font_id)?; // Iterate over fonts and check if any of them support the specified char. for face in fontdb.faces() { // Ignore fonts, that were used for shaping already. - if exclude_fonts.contains(&face.id) { + if request.exclude_fonts.contains(&face.id) { continue; } // Check that the new face has the same style. - let base_face = fontdb.face(base_font_id)?; if base_face.style != face.style && base_face.weight != face.weight && base_face.stretch != face.stretch @@ -164,7 +197,7 @@ impl FontResolver<'_> { continue; } - if !fontdb.has_char(face.id, c) { + if !fontdb.has_char(face.id, request.character) { continue; } @@ -214,3 +247,60 @@ pub(crate) fn convert(text: &mut Text, resolver: &FontResolver, cache: &mut Cach Some(()) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use once_cell::sync::Lazy; + + use super::*; + + static TEST_FONTDB: Lazy> = Lazy::new(|| { + let mut fontdb = Database::new(); + let fonts_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../resvg/tests/fonts"); + fontdb.load_fonts_dir(fonts_dir); + Arc::new(fontdb) + }); + + fn test_font(families: &[&str]) -> Font { + Font { + families: families + .iter() + .map(|family| FontFamily::Named((*family).to_string())) + .collect(), + style: crate::FontStyle::Normal, + stretch: crate::FontStretch::Normal, + weight: 400, + variations: Vec::new(), + } + } + + #[test] + fn default_fallback_selector_prefers_declared_families() { + let mut fontdb = TEST_FONTDB.clone(); + let font = test_font(&["Noto Sans", "Noto Sans Devanagari"]); + + let select_font = FontResolver::default_font_selector(); + let base_font_id = select_font(&font, &mut fontdb).unwrap(); + + let select_fallback = FontResolver::default_fallback_selector(); + let fallback_id = select_fallback( + FallbackRequest { + character: 'क', + font: &font, + exclude_fonts: &[base_font_id], + }, + &mut fontdb, + ) + .unwrap(); + + let face = fontdb.face(fallback_id).unwrap(); + assert!( + face.families + .iter() + .any(|family| family.0 == "Noto Sans Devanagari") + ); + } +} diff --git a/crates/usvg/tests/parser.rs b/crates/usvg/tests/parser.rs index a8e65f8f4..fbeeb0dab 100644 --- a/crates/usvg/tests/parser.rs +++ b/crates/usvg/tests/parser.rs @@ -1,9 +1,21 @@ // Copyright 2018 the Resvg Authors // SPDX-License-Identifier: Apache-2.0 OR MIT +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use once_cell::sync::Lazy; use tiny_skia_path::Rect; use usvg::Color; +static TEST_FONTDB: Lazy> = Lazy::new(|| { + let mut fontdb = usvg::fontdb::Database::new(); + let fonts_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../resvg/tests/fonts"); + fontdb.load_fonts_dir(fonts_dir); + Arc::new(fontdb) +}); + #[test] fn clippath_with_invalid_child() { let svg = " @@ -547,3 +559,76 @@ fn no_text_nodes() { let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap(); assert!(!tree.has_text_nodes()); } + +#[test] +fn custom_fallback_resolver_receives_request_before_default_policy() { + let calls = Arc::new(AtomicUsize::new(0)); + let fallback_calls = calls.clone(); + + let opt = usvg::Options { + fontdb: TEST_FONTDB.clone(), + font_resolver: usvg::FontResolver { + select_font: usvg::FontResolver::default_font_selector(), + select_fallback: Box::new(move |request, _db| { + fallback_calls.fetch_add(1, Ordering::SeqCst); + + assert_eq!(request.character, 'क'); + assert_eq!(request.exclude_fonts.len(), 1); + assert_eq!( + request + .font + .families() + .iter() + .map(|family| family.to_string()) + .collect::>(), + vec![ + "\"Noto Sans\"".to_string(), + "\"Noto Sans Devanagari\"".to_string(), + ] + ); + + None + }), + }, + ..usvg::Options::default() + }; + + let svg = " + + + + "; + + let _ = usvg::Tree::from_str(&svg, &opt).unwrap(); + assert!(calls.load(Ordering::SeqCst) > 0); +} + +#[test] +fn invalid_child_font_family_keeps_fallback_glyphs_visible() { + let opt = usvg::Options { + fontdb: TEST_FONTDB.clone(), + ..usvg::Options::default() + }; + + let svg = " + + + data 你好 data + + + "; + + let tree = usvg::Tree::from_str(svg, &opt).unwrap(); + let usvg::Node::Text(text) = &tree.root().children()[0] else { + unreachable!() + }; + + let rendered_text = text + .layouted() + .iter() + .flat_map(|span| span.positioned_glyphs.iter()) + .map(|glyph| glyph.text.as_str()) + .collect::(); + + assert!(rendered_text.contains("你好")); +} From de749971dd88171701f75d485abb3d24ff85c2cc Mon Sep 17 00:00:00 2001 From: thekingofcity <3353040+thekingofcity@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:34:47 +0800 Subject: [PATCH 4/4] Handle complex fallback runs conservatively --- crates/usvg/src/text/layout.rs | 62 ++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs index dffcca46b..f5fb019d8 100644 --- a/crates/usvg/src/text/layout.rs +++ b/crates/usvg/src/text/layout.rs @@ -1378,9 +1378,14 @@ pub(crate) fn shape_text( ) .unwrap_or_default(); - // We assume, that shaping with an any font will produce the same amount of glyphs. - // This is incorrect, but good enough for now. - if glyphs.len() != fallback_glyphs.len() { + let all_matched = fallback_glyphs.iter().all(|g| !g.is_missing()); + if !can_do_partial_fallback(text, &glyphs, &fallback_glyphs) { + if all_matched { + // If the fallback can shape the full run but the cluster layout + // no longer matches the current shaping, we have to keep the + // fallback as a whole run to preserve shaping correctness. + glyphs = fallback_glyphs; + } break 'outer; } @@ -1575,6 +1580,57 @@ impl Iterator for GlyphClusters<'_> { } } +/// Returns whether it is safe to keep the existing run and only fill missing +/// glyphs from `fallback_glyphs`. +/// +/// This is intentionally conservative. We only allow partial fallback when both +/// shapings still agree on the cluster boundaries and on the number of glyphs +/// per cluster. If they diverge, a glyph-by-glyph merge can break shaping for +/// the whole run, so callers should prefer whole-run fallback instead. +fn can_do_partial_fallback(text: &str, glyphs: &[Glyph], fallback_glyphs: &[Glyph]) -> bool { + let mut glyph_clusters = GlyphClusters::new(glyphs); + let mut fallback_clusters = GlyphClusters::new(fallback_glyphs); + + loop { + match (glyph_clusters.next(), fallback_clusters.next()) { + (Some((glyph_range, glyph_byte_idx)), Some((fallback_range, fallback_byte_idx))) => { + if glyph_byte_idx != fallback_byte_idx { + return false; + } + + let Some(glyph) = glyphs.get(glyph_range.start) else { + return false; + }; + let Some(fallback_glyph) = fallback_glyphs.get(fallback_range.start) else { + return false; + }; + + if glyph.cluster_len != fallback_glyph.cluster_len { + return false; + } + + if glyph_range.len() != fallback_range.len() { + return false; + } + + let cluster_needs_fallback = + glyphs[glyph_range.clone()].iter().any(Glyph::is_missing); + // For scripts where spacing and shaping are tightly coupled, even + // matching cluster boundaries are not enough to make partial + // fallback trustworthy. In those cases we keep the whole-run + // fallback path as the safer option. + if cluster_needs_fallback + && !script_supports_letter_spacing(glyph_byte_idx.char_from(text).script()) + { + return false; + } + } + (None, None) => return true, + _ => return false, + } + } +} + /// Checks that selected script supports letter spacing. /// /// [In the CSS spec](https://www.w3.org/TR/css-text-3/#cursive-tracking).