From f62144ec67dea0285995dadb587639732b1c9ff0 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 16 Jul 2026 15:43:21 +0200 Subject: [PATCH] fix missing glyphs --- frontend/src/app/render_wasm/api/fonts.cljs | 16 +- render-wasm/src/render/fonts.rs | 37 +- render-wasm/src/shapes/text_vertical.rs | 381 +++++++++++--------- render-wasm/src/wasm/fonts.rs | 8 + 4 files changed, 276 insertions(+), 166 deletions(-) diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 86946db5ba..1c4f167dca 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -211,6 +211,17 @@ (:style font-data) emoji?)))) +(defn- mark-font-as-fallback! + [font-data] + (let [id-buffer (uuid/get-u32 (:wasm-id font-data))] + (h/call wasm/internal-module "_mark_font_as_fallback" + (aget id-buffer 0) + (aget id-buffer 1) + (aget id-buffer 2) + (aget id-buffer 3) + (:weight font-data) + (:style font-data)))) + (defn- store-font-id [font-data asset-id emoji? fallback?] (when asset-id @@ -223,7 +234,10 @@ font-data (assoc font-data :family-id-buffer id-buffer) font-stored? (font-stored? font-data emoji?)] (if font-stored? - (st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) + (do + (when fallback? + (mark-font-as-fallback! font-data)) + (st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)}))) (fetch-font font-data uri emoji? fallback?))))) (defn serialize-font-style diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index 17bdb84290..78da2d631f 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -86,7 +86,11 @@ impl FontStore { is_emoji: bool, is_fallback: bool, ) -> Result<()> { + let alias = format!("{}", family); if self.has_family(&family, is_emoji) { + if is_fallback { + self.fallback_fonts.insert(alias); + } return Ok(()); } @@ -97,7 +101,6 @@ impl FontStore { "Failed to create typeface".to_string(), ))?; - let alias = format!("{}", family); let font_name = if is_emoji { DEFAULT_EMOJI_FONT } else { @@ -114,6 +117,16 @@ impl FontStore { Ok(()) } + /// Upgrade an already-uploaded family to participate in character + /// fallback. Font bytes are cached independently from the role in which a + /// face is used, so a family may first arrive as a document font and only + /// later be requested as a fallback. + pub fn mark_as_fallback(&mut self, family: &FontFamily) { + if self.has_family(family, false) { + self.fallback_fonts.insert(format!("{}", family)); + } + } + pub fn has_family(&self, family: &FontFamily, is_emoji: bool) -> bool { let alias = format!("{}", family); let font_name = if is_emoji { @@ -144,3 +157,25 @@ fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontPr font_provider } + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::FontStyle; + use crate::Uuid; + + const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf"); + + #[test] + fn uploaded_family_can_be_upgraded_to_fallback() { + let mut store = FontStore::try_new().unwrap(); + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + let alias = format!("{}", family); + + store.add(family, TEST_FONT, false, false).unwrap(); + assert!(!store.get_fallback().contains(&alias)); + + store.mark_as_fallback(&family); + assert!(store.get_fallback().contains(&alias)); + } +} diff --git a/render-wasm/src/shapes/text_vertical.rs b/render-wasm/src/shapes/text_vertical.rs index ea6f49074d..96a97988b6 100644 --- a/render-wasm/src/shapes/text_vertical.rs +++ b/render-wasm/src/shapes/text_vertical.rs @@ -328,6 +328,94 @@ fn shape_segment( collector.runs } +/// Shape a segment with explicit per-character font fallback. Penpot's +/// `TypefaceFontProvider` can resolve named families, but its character +/// fallback hook is not available consistently in every Skia build. Resolve +/// coverage here so missing glyphs never depend on that hook. +fn shape_segment_with_fallbacks( + text: &str, + font_size: f32, + families: &[String], + font_provider: &TypefaceFontProvider, + upright: bool, + font_features: FontFeatures, + fallback_mgr: &FontMgr, +) -> Vec { + let typefaces: Vec<_> = families + .iter() + .filter_map(|family| font_provider.match_family_style(family, skia::FontStyle::default())) + .collect(); + let Some(primary) = typefaces.first() else { + return Vec::new(); + }; + + // Combining marks, variation selectors and ZWJ belong to the preceding + // grapheme. Keep them in that font run even when they have no standalone + // cmap entry; HarfBuzz then gets the complete shaping sequence. + let extends_previous = |ch: char| { + matches!(u32::from(ch), + 0x0300..=0x036F + | 0x1AB0..=0x1AFF + | 0x1DC0..=0x1DFF + | 0x200D + | 0x20D0..=0x20FF + | 0x3099..=0x309A + | 0xFE00..=0xFE0F + | 0xFE20..=0xFE2F + | 0xE0100..=0xE01EF + ) + }; + + let mut chunks: Vec<(std::ops::Range, usize)> = Vec::new(); + let mut current_typeface = 0usize; + let mut chunk_start = 0usize; + let mut has_character = false; + for (byte, ch) in text.char_indices() { + let selected = if has_character && extends_previous(ch) { + current_typeface + } else { + typefaces + .iter() + .position(|typeface| typeface.unichar_to_glyph(ch as i32) != 0) + .unwrap_or(0) + }; + if has_character && selected != current_typeface { + chunks.push((chunk_start..byte, current_typeface)); + chunk_start = byte; + } + current_typeface = selected; + has_character = true; + } + if has_character { + chunks.push((chunk_start..text.len(), current_typeface)); + } + + let mut result = Vec::new(); + for (range, typeface_index) in chunks { + let typeface = typefaces + .get(typeface_index) + .cloned() + .unwrap_or_else(|| primary.clone()); + let font = Font::new(typeface, font_size); + let mut shaped = shape_segment( + &text[range.clone()], + &font, + upright, + font_features, + fallback_mgr.clone(), + ); + for run in &mut shaped { + run.utf8_range = + (run.utf8_range.start + range.start)..(run.utf8_range.end + range.start); + for cluster in &mut run.clusters { + *cluster += range.start as u32; + } + } + result.extend(shaped); + } + result +} + /// Vertical advances from a font's `vhea`/`vmtx` tables. Upright cells /// advance down the column by the glyph's true vertical advance rather /// than its shaped horizontal advance: identical for full-width CJK, but @@ -1574,32 +1662,14 @@ fn try_push_tcy_composite( } let letter_spacing = span.letter_spacing; - let probe = piece.chars().next().unwrap_or(' '); - let candidates = || { - families.iter().filter_map(|family| { - font_provider.match_family_style(family, skia::FontStyle::default()) - }) - }; - // Prefer a face that covers the whole run; else one covering the first - // character; else the span's own face. Any glyph still uncovered is - // shaped through the fallback manager, which adds extra runs that - // compose into the same upright cell. - let typeface = candidates() - .find(|tf| piece.chars().all(|c| tf.unichar_to_glyph(c as i32) != 0)) - .or_else(|| candidates().find(|tf| tf.unichar_to_glyph(probe as i32) != 0)) - .or_else(|| { - font_provider.match_family_style(families[0].as_str(), skia::FontStyle::default()) - }); - let Some(typeface) = typeface else { - return false; - }; - let font = Font::new(typeface, span.font_size); - let mut shaped = shape_segment( + let mut shaped = shape_segment_with_fallbacks( piece, - &font, + span.font_size, + families, + font_provider, false, span.font_features, - fallback_mgr.clone(), + fallback_mgr, ); if shaped.is_empty() { return false; @@ -1810,11 +1880,9 @@ pub fn layout_vertical( paints.push(merge_fills(&span.fills, bounds)); let paint_index = paints.len() - 1; - // Candidate families: the span's own font first, then the - // registered fallback fonts (Noto etc.). The typeface font - // provider does no per-character fallback, so pick the first - // family that covers the whole segment (falling back to one that - // covers its first character). + // Candidate families: the span's own font first, then emoji and + // registered fallback fonts (Noto etc.). Shaping resolves glyph + // coverage explicitly and splits runs at typeface boundaries. let families = span_font_families(span, fallback_families); let orientation = span.text_orientation; @@ -1848,88 +1916,76 @@ pub fn layout_vertical( // balanced by character count with the first line the longer one, // nudged so the sub-lines respect kinsoku. if span.warichu && text.chars().count() >= 2 { - let candidates = || { - families.iter().filter_map(|family| { - font_provider.match_family_style(family, skia::FontStyle::default()) - }) - }; - let probe = text.chars().next().unwrap_or(' '); - let typeface = candidates() - .find(|tf| text.chars().all(|c| tf.unichar_to_glyph(c as i32) != 0)) - .or_else(|| candidates().find(|tf| tf.unichar_to_glyph(probe as i32) != 0)) - .or_else(|| { - font_provider - .match_family_style(families[0].as_str(), skia::FontStyle::default()) + let half_size = span.font_size * WARICHU_FONT_SCALE; + let split_chars = warichu_split_chars(&text); + let split_utf8 = text + .char_indices() + .nth(split_chars) + .map(|(i, _)| i) + .unwrap_or(text.len()); + let (first_text, second_text) = text.split_at(split_utf8); + let first_chars = first_text.encode_utf16().count(); + let mut first_runs = shape_segment_with_fallbacks( + first_text, + half_size, + &families, + font_provider, + true, + span.font_features, + &fallback_mgr, + ); + let mut second_runs = shape_segment_with_fallbacks( + second_text, + half_size, + &families, + font_provider, + true, + span.font_features, + &fallback_mgr, + ); + if !first_runs.is_empty() && !second_runs.is_empty() { + let first_extent: f32 = first_runs.iter().map(|r| r.advance).sum(); + let second_extent: f32 = second_runs.iter().map(|r| r.advance).sum(); + let extent = first_extent.max(second_extent) + letter_spacing; + let run_start = runs.len(); + let first_count = first_runs.len(); + let run_count = first_count + second_runs.len(); + runs.append(&mut first_runs); + runs.append(&mut second_runs); + let end = span_utf16_offset + text.encode_utf16().count(); + para_cells.push(VerticalCell { + kind: CellKind::Warichu { + run_start, + run_count, + first_count, + first_chars, + }, + paragraph: paragraph_index, + span: span_index, + start: span_utf16_offset, + end, + column: 0, + top: 0.0, + extent, + minimum_oikomi_extent: extent, + // Two half-em sub-columns side by side fill the em. + h_advance: span.font_size, + ink_top: 0.0, + ink_bottom: extent - letter_spacing, + paint: paint_index, + font_size: span.font_size, + decoration: span.text_decoration, + glyph_flow_shift: 0.0, }); - if let Some(typeface) = typeface { - let half_size = span.font_size * WARICHU_FONT_SCALE; - let font = Font::new(typeface, half_size); - let split_chars = warichu_split_chars(&text); - let split_utf8 = text - .char_indices() - .nth(split_chars) - .map(|(i, _)| i) - .unwrap_or(text.len()); - let (first_text, second_text) = text.split_at(split_utf8); - let first_chars = first_text.encode_utf16().count(); - let mut first_runs = shape_segment( - first_text, - &font, - true, - span.font_features, - fallback_mgr.clone(), - ); - let mut second_runs = shape_segment( - second_text, - &font, - true, - span.font_features, - fallback_mgr.clone(), - ); - if !first_runs.is_empty() && !second_runs.is_empty() { - let first_extent: f32 = first_runs.iter().map(|r| r.advance).sum(); - let second_extent: f32 = second_runs.iter().map(|r| r.advance).sum(); - let extent = first_extent.max(second_extent) + letter_spacing; - let run_start = runs.len(); - let first_count = first_runs.len(); - let run_count = first_count + second_runs.len(); - runs.append(&mut first_runs); - runs.append(&mut second_runs); - let end = span_utf16_offset + text.encode_utf16().count(); - para_cells.push(VerticalCell { - kind: CellKind::Warichu { - run_start, - run_count, - first_count, - first_chars, - }, - paragraph: paragraph_index, - span: span_index, - start: span_utf16_offset, - end, - column: 0, - top: 0.0, - extent, - minimum_oikomi_extent: extent, - // Two half-em sub-columns side by side fill the em. - h_advance: span.font_size, - ink_top: 0.0, - ink_bottom: extent - letter_spacing, - paint: paint_index, - font_size: span.font_size, - decoration: span.text_decoration, - glyph_flow_shift: 0.0, - }); - items.push(FlowItem { - extent, - ch: None, - keep_with_previous: false, - }); - scripts.push(FlowScript::Upright); - trailing_spacings.push(letter_spacing); - span_utf16_offset += text.encode_utf16().count(); - continue; - } + items.push(FlowItem { + extent, + ch: None, + keep_with_previous: false, + }); + scripts.push(FlowScript::Upright); + trailing_spacings.push(letter_spacing); + span_utf16_offset += text.encode_utf16().count(); + continue; } } // `digits` combines runs of 2..=max consecutive ASCII or full-width digits @@ -1968,29 +2024,14 @@ pub fn layout_vertical( continue; } for segment in segment_by_orientation(piece_text, orientation) { - let probe = segment.text.chars().next().unwrap_or(' '); - let typeface = families - .iter() - .filter_map(|family| { - font_provider.match_family_style(family, skia::FontStyle::default()) - }) - .find(|tf| tf.unichar_to_glyph(probe as i32) != 0) - .or_else(|| { - font_provider.match_family_style( - families[0].as_str(), - skia::FontStyle::default(), - ) - }); - let Some(typeface) = typeface else { - continue; - }; - let font = Font::new(typeface, span.font_size); - let shaped = shape_segment( + let shaped = shape_segment_with_fallbacks( &segment.text, - &font, + span.font_size, + &families, + font_provider, segment.upright, span.font_features, - fallback_mgr.clone(), + &fallback_mgr, ); // Map UTF-8 offsets in the segment text to UTF-16 offsets @@ -2519,28 +2560,15 @@ pub fn layout_vertical( } let ruby_font_size = span.font_size * span.ruby_size.scale(); - let probe = ruby_text.chars().next().unwrap_or(' '); let families = span_font_families(span, fallback_families); - let typeface = families - .iter() - .filter_map(|family| { - font_provider.match_family_style(family, skia::FontStyle::default()) - }) - .find(|tf| tf.unichar_to_glyph(probe as i32) != 0) - .or_else(|| { - font_provider - .match_family_style(families[0].as_str(), skia::FontStyle::default()) - }); - let Some(typeface) = typeface else { - continue; - }; - let font = Font::new(typeface, ruby_font_size); - let shaped = shape_segment( + let shaped = shape_segment_with_fallbacks( ruby_text, - &font, + ruby_font_size, + &families, + font_provider, true, span.font_features, - fallback_mgr.clone(), + &fallback_mgr, ); if shaped.is_empty() { continue; @@ -3297,26 +3325,14 @@ pub fn paint_horizontal_ruby( skia::textlayout::RectHeightStyle::Tight, skia::textlayout::RectWidthStyle::Tight, ); - let probe = ruby_text.chars().next().unwrap_or(' '); - let typeface = families - .iter() - .filter_map(|family| { - font_provider.match_family_style(family, skia::FontStyle::default()) - }) - .find(|tf| tf.unichar_to_glyph(probe as i32) != 0) - .or_else(|| { - font_provider.match_family_style(families[0].as_str(), skia::FontStyle::default()) - }); - let Some(typeface) = typeface else { - continue; - }; - let font = Font::new(typeface, ruby_font_size); - let shaped = shape_segment( + let shaped = shape_segment_with_fallbacks( &ruby_text, - &font, + ruby_font_size, + &families, + font_provider, false, span.font_features, - fallback_mgr.clone(), + &fallback_mgr, ); let glyphs: Vec<(usize, usize, f32)> = shaped .iter() @@ -4936,6 +4952,43 @@ mod tests { ); } + #[test] + fn vertical_base_text_switches_font_inside_an_upright_segment() { + let content = make_content(&["あ、く"], 400.0); + let font_mgr = FontMgr::new(); + let primary = font_mgr.new_from_data(VMTX_TEST_FONT, None).unwrap(); + let fallback = font_mgr.new_from_data(VPAL_TEST_FONT, None).unwrap(); + let mut provider = TypefaceFontProvider::new(); + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + provider.register_typeface(primary, Some(family.as_str())); + provider.register_typeface(fallback, Some("base-fallback")); + let fallback_mgr = FontMgr::from(provider.clone()); + let layout = layout_vertical( + &content, + 400.0, + &provider, + fallback_mgr, + &["base-fallback".to_string()], + content.bounds(), + ); + + assert_eq!(layout.cells.len(), 3); + let run_ids: Vec = layout + .cells + .iter() + .map(|cell| match cell.kind { + CellKind::Upright { run, .. } => layout.runs[run].font.typeface().unique_id(), + _ => panic!("expected upright fallback cells"), + }) + .collect(); + assert_eq!(run_ids[0], run_ids[2]); + assert_ne!(run_ids[0], run_ids[1]); + let CellKind::Upright { run, glyph, .. } = layout.cells[1].kind else { + unreachable!(); + }; + assert_ne!(layout.runs[run].glyphs[glyph], 0); + } + #[test] fn ruby_selection_geometry_excludes_gutter() { let content = make_ruby_content("AB", "ab", 400.0); diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index 21c4e6a797..1188262332 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -70,3 +70,11 @@ pub extern "C" fn is_font_uploaded( res } + +#[no_mangle] +pub extern "C" fn mark_font_as_fallback(a: u32, b: u32, c: u32, d: u32, weight: u32, style: u8) { + let id = uuid_from_u32_quartet(a, b, c, d); + let font_style = RawFontStyle::from(style); + let family = FontFamily::new(id, weight, font_style.into()); + get_render_state().fonts_mut().mark_as_fallback(&family); +}