From 6b60ca1353981e9c18b37e3446816ccca9726535 Mon Sep 17 00:00:00 2001 From: gcailly <109429289+gcailly@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:00:59 +0100 Subject: [PATCH] Integrate harfrust for OpenType text shaping in epaint Replace the character-by-character glyph lookup in `layout_section()` with a proper text shaping pipeline using harfrust (a Rust port of HarfBuzz). This enables GPOS kerning, ligatures (fi, fl), and correct positioning of combining diacritical marks. The shaping pipeline works as follows: 1. Split text into font-fallback runs (grapheme-cluster-aware) 2. Shape each run with harfrust (GSUB + GPOS) 3. Allocate and position glyphs from the shaping output Key changes: - Add harfrust, unicode-segmentation, unicode-general-category deps - Cache ShaperData on FontFace (parsed GSUB/GPOS tables) - Add shape_text() with buffer flags and variable font support - Add allocate_glyph_by_id() for shaper-produced glyph IDs - Recycle harfrust UnicodeBuffer across layout calls - Handle NOTDEF fallback (combining marks via unicode-general-category) Addresses #2517. --- Cargo.lock | 23 ++ Cargo.toml | 2 + crates/epaint/Cargo.toml | 3 + crates/epaint/src/text/font.rs | 153 ++++++++++ crates/epaint/src/text/fonts.rs | 16 ++ crates/epaint/src/text/text_layout.rs | 398 ++++++++++++++++++++++---- 6 files changed, 532 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 981e7c475..b84b4aeaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1548,6 +1548,7 @@ dependencies = [ "emath", "epaint_default_fonts", "font-types", + "harfrust", "log", "mimalloc", "nohash-hasher", @@ -1559,6 +1560,8 @@ dependencies = [ "similar-asserts", "skrifa", "smallvec", + "unicode-general-category", + "unicode-segmentation", "vello_cpu", ] @@ -2037,6 +2040,19 @@ dependencies = [ "num-traits", ] +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.9.4", + "bytemuck", + "core_maths", + "read-fonts", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.15.2" @@ -3698,6 +3714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" dependencies = [ "bytemuck", + "core_maths", "font-types", ] @@ -4676,6 +4693,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index cf631eb25..571bd35d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,8 @@ ehttp = { version = "0.7.1", default-features = false } enum-map = "2.7.3" env_logger = { version = "0.11.8", default-features = false } font-types = { version = "0.11.0", default-features = false, features = ["std"] } +harfrust = "0.5.2" +unicode-general-category = "1.1.0" glow = "0.17.0" glutin = { version = "0.32.3", default-features = false } glutin-winit = { version = "0.5.0", default-features = false } diff --git a/crates/epaint/Cargo.toml b/crates/epaint/Cargo.toml index 9d6e3eade..705f1f6a9 100644 --- a/crates/epaint/Cargo.toml +++ b/crates/epaint/Cargo.toml @@ -63,6 +63,7 @@ ecolor.workspace = true ahash.workspace = true font-types.workspace = true +harfrust.workspace = true log.workspace = true nohash-hasher.workspace = true parking_lot.workspace = true # Using parking_lot over std::sync::Mutex gives 50% speedups in some real-world scenarios. @@ -70,6 +71,8 @@ profiling.workspace = true self_cell.workspace = true skrifa.workspace = true smallvec.workspace = true +unicode-general-category.workspace = true +unicode-segmentation.workspace = true vello_cpu.workspace = true #! ### Optional dependencies diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index 61f9f9f2f..d675d28b8 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -337,6 +337,10 @@ pub struct FontFace { font: FontCell, tweak: FontTweak, + /// Cached `harfrust` shaper data (parsed GSUB/GPOS tables). + /// `ShaperData` is `Copy` — lives outside the `self_cell`. + shaper_data: harfrust::ShaperData, + glyph_info_cache: ahash::HashMap, glyph_alloc_cache: ahash::HashMap, } @@ -393,10 +397,13 @@ impl FontFace { }) })?; + let shaper_data = harfrust::ShaperData::new(&font.borrow_dependent().skrifa); + Ok(Self { name, font, tweak, + shaper_data, glyph_info_cache: Default::default(), glyph_alloc_cache: Default::default(), }) @@ -571,6 +578,103 @@ impl FontFace { } } + /// Shape a text run and return the raw [`harfrust::GlyphBuffer`]. + /// + /// The caller should iterate `glyph_infos()` / `glyph_positions()` (both + /// `Copy` slices) and convert font units to pixels using `metrics.px_scale_factor`. + /// After iteration, recycle the buffer via `glyph_buffer.clear()`. + pub fn shape_text( + &self, + text: &str, + coords: &VariationCoords, + mut buffer: harfrust::UnicodeBuffer, + flags: harfrust::BufferFlags, + ) -> harfrust::GlyphBuffer { + let font_ref = &self.font.borrow_dependent().skrifa; + + // Build shaper with variable font instance if variation coordinates are set. + let variations: Vec = self + .tweak + .coords + .as_ref() + .iter() + .chain(coords.as_ref().iter()) + .map(|&(tag, value)| harfrust::Variation { tag, value }) + .collect(); + + let instance = if variations.is_empty() { + None + } else { + Some(harfrust::ShaperInstance::from_variations( + font_ref, + variations, + )) + }; + + let shaper = self + .shaper_data + .shaper(font_ref) + .instance(instance.as_ref()) + .build(); + + buffer.set_flags(flags); + buffer.push_str(text); + buffer.guess_segment_properties(); + + shaper.shape(buffer, &[]) + } + + /// Allocate a glyph by its ID directly (from shaping output). + /// + /// `shaper_y_offset_points` is the vertical offset from the shaper, already in points. + #[expect(clippy::too_many_arguments)] + pub fn allocate_glyph_by_id( + &mut self, + atlas: &mut TextureAtlas, + metrics: &StyledMetrics, + glyph_id: skrifa::GlyphId, + advance_width_px: f32, + h_pos: f32, + shaper_y_offset_points: f32, + is_cjk_glyph: bool, + ) -> (GlyphAllocation, i32) { + if glyph_id == skrifa::GlyphId::NOTDEF { + return (GlyphAllocation::default(), h_pos as i32); + } + + let (h_pos_round, bin) = if is_cjk_glyph { + (h_pos.round() as i32, SubpixelBin::Zero) + } else { + SubpixelBin::new(h_pos) + }; + + let cache_key = GlyphCacheKey::new(glyph_id, metrics, bin); + if let Some(cached) = self.glyph_alloc_cache.get(&cache_key) { + let mut alloc = *cached; + alloc.advance_width_px = advance_width_px; + alloc.uv_rect.offset.y += shaper_y_offset_points; + return (alloc, h_pos_round); + } + + let glyph_info = GlyphInfo { + id: Some(glyph_id), + advance_width_unscaled: OrderedFloat(advance_width_px / metrics.px_scale_factor), + }; + + let mut allocation = self + .font + .allocate_glyph_uncached(atlas, metrics, &glyph_info, bin, (&metrics.location).into()) + .unwrap_or_default(); + + // Cache the allocation WITHOUT the shaper y_offset (which varies per call) + self.glyph_alloc_cache.insert(cache_key, allocation); + + // Apply shaper y_offset after caching (Option A from plan) + allocation.uv_rect.offset.y += shaper_y_offset_points; + + (allocation, h_pos_round) + } + pub fn allocate_glyph( &mut self, atlas: &mut TextureAtlas, @@ -616,6 +720,18 @@ impl FontFace { } } +/// A contiguous run of text that maps to a single font face. +/// +/// Produced by [`Font::segment_into_runs`] for text shaping. +#[derive(Debug)] +pub(crate) struct TextRun { + /// Which font face should shape this run. + pub font_key: FontFaceKey, + + /// Byte range within the section text. + pub byte_range: std::ops::Range, +} + // TODO(emilk): rename? /// Wrapper over multiple [`FontFace`] (e.g. a primary + fallbacks for emojis) pub struct Font<'a> { @@ -679,6 +795,43 @@ impl Font<'_> { s.chars().all(|c| self.has_glyph(c)) } + /// Segment text into runs where each run uses a single font face. + /// + /// Grapheme clusters are never split across runs: if a combining mark + /// falls back to a different font than its base character, it stays + /// with the base character's font (the shaper will handle it). + /// Segment text into runs where each run uses a single font face. + /// + /// Grapheme clusters are never split across runs: if a combining mark + /// falls back to a different font than its base character, it stays + /// with the base character's font (the shaper will handle it). + /// + /// Results are appended to `out` (which is cleared first) to allow + /// the caller to reuse the allocation across calls. + pub(crate) fn segment_into_runs(&mut self, text: &str, out: &mut Vec) { + use unicode_segmentation::UnicodeSegmentation as _; + + out.clear(); + + for (byte_offset, grapheme_str) in text.grapheme_indices(true) { + let byte_end = byte_offset + grapheme_str.len(); + + let base_char = grapheme_str.chars().next().unwrap_or(' '); + let (font_key, _) = self.glyph_info(base_char); + + if let Some(last_run) = out.last_mut() + && last_run.font_key == font_key + { + last_run.byte_range.end = byte_end; + continue; + } + out.push(TextRun { + font_key, + byte_range: byte_offset..byte_end, + }); + } + } + /// `\n` will (intentionally) show up as the replacement character. pub(crate) fn glyph_info(&mut self, c: char) -> (FontFaceKey, GlyphInfo) { if let Some(font_index_glyph_info) = self.cached_family.glyph_info_cache.get(&c) { diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 5099e0085..8254325b5 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -765,6 +765,9 @@ pub struct FontsImpl { fonts_by_id: nohash_hasher::IntMap, fonts_by_name: ahash::HashMap, family_cache: ahash::HashMap, + + /// Recycled `harfrust` shaping buffer to avoid per-layout allocations. + shape_buffer: Option, } impl FontsImpl { @@ -798,6 +801,7 @@ impl FontsImpl { fonts_by_id, fonts_by_name, family_cache: Default::default(), + shape_buffer: Some(harfrust::UnicodeBuffer::new()), } } @@ -805,6 +809,18 @@ impl FontsImpl { self.atlas.options() } + /// Take the recycled shaping buffer (or create a new one if already taken). + pub fn take_shape_buffer(&mut self) -> harfrust::UnicodeBuffer { + self.shape_buffer + .take() + .unwrap_or_default() + } + + /// Return a shaping buffer for reuse. + pub fn return_shape_buffer(&mut self, buffer: harfrust::UnicodeBuffer) { + self.shape_buffer = Some(buffer); + } + /// Get the right font implementation from [`FontFamily`]. pub fn font(&mut self, family: &FontFamily) -> Font<'_> { let cached_family = self.family_cache.entry(family.clone()).or_insert_with(|| { diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 0233c1c58..5ff350e2b 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -7,16 +7,31 @@ use emath::{Align, GuiRounding as _, NumExt as _, Pos2, Rect, Vec2, pos2, vec2}; use crate::{ Color32, Mesh, Stroke, Vertex, stroke::PathStroke, - text::{ - font::{StyledMetrics, is_cjk, is_cjk_break_allowed}, - fonts::FontFaceKey, - }, + text::font::{StyledMetrics, is_cjk, is_cjk_break_allowed}, }; -use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row, RowVisuals}; +use super::{ + FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row, RowVisuals, + font::{Font, TextRun}, +}; // ---------------------------------------------------------------------------- +/// Returns `true` if the character is a Unicode combining mark (categories Mn, Mc, Me). +/// +/// These characters modify the preceding base character and should not be +/// rendered as standalone replacement glyphs when the shaper can't handle them. +#[inline] +fn is_combining_mark(c: char) -> bool { + use unicode_general_category::{GeneralCategory, get_general_category}; + matches!( + get_general_category(c), + GeneralCategory::NonspacingMark + | GeneralCategory::SpacingMark + | GeneralCategory::EnclosingMark + ) +} + /// Represents GUI scale and convenience methods for rounding to pixels. #[derive(Clone, Copy)] struct PointScale { @@ -144,6 +159,118 @@ pub fn layout(fonts: &mut FontsImpl, pixels_per_point: f32, job: Arc) galley_from_rows(point_scale, job, rows, elided, intrinsic_size) } +/// Shared context for emitting shaped glyphs into a [`Paragraph`]. +struct ShapingContext { + pixels_per_point: f32, + line_height: f32, + extra_letter_spacing: f32, + section_index: u32, + font_metrics: StyledMetrics, + is_first_glyph_in_section: bool, + prev_cluster: Option, +} + +/// Emit shaped glyphs from a [`harfrust::GlyphBuffer`] into a [`Paragraph`]. +fn layout_shaped_run( + font: &mut Font<'_>, + run: &TextRun, + run_text: &str, + glyph_buffer: &harfrust::GlyphBuffer, + face_metrics: &StyledMetrics, + ctx: &mut ShapingContext, + paragraph: &mut Paragraph, +) { + let px_scale = face_metrics.px_scale_factor; + + for (info, pos) in glyph_buffer + .glyph_infos() + .iter() + .zip(glyph_buffer.glyph_positions()) + { + let glyph_id = skrifa::GlyphId::new(info.glyph_id); + let cluster = info.cluster; + let x_advance_px = pos.x_advance as f32 * px_scale; + let x_offset_px = pos.x_offset as f32 * px_scale; + let y_offset_px = -(pos.y_offset as f32 * px_scale); // harfrust Y+ up → screen Y+ down + + let chr = run_text + .get(cluster as usize..) + .and_then(|s| s.chars().next()) + .unwrap_or('\u{FFFD}'); + + // Apply extra_letter_spacing only at cluster boundaries, + // never between glyphs within the same cluster (e.g. base + mark). + let is_new_cluster = ctx.prev_cluster.is_none_or(|pc| pc != cluster); + if !ctx.is_first_glyph_in_section && is_new_cluster { + paragraph.cursor_x_px += ctx.extra_letter_spacing * ctx.pixels_per_point; + } + if is_new_cluster { + ctx.is_first_glyph_in_section = false; + } + ctx.prev_cluster = Some(cluster); + + if glyph_id == skrifa::GlyphId::NOTDEF { + // The shaper couldn't map this character. Drop combining marks + // (Unicode category M) and duplicate NOTDEF glyphs within the same + // cluster — only the first base character gets a replacement glyph. + if is_combining_mark(chr) || !is_new_cluster { + continue; + } + + let (_, glyph_info) = font.glyph_info(chr); + let (glyph_alloc, physical_x) = + if let Some(ff) = font.fonts_by_id.get_mut(&run.font_key) { + ff.allocate_glyph(font.atlas, face_metrics, glyph_info, chr, paragraph.cursor_x_px) + } else { + Default::default() + }; + + paragraph.glyphs.push(Glyph { + chr, + pos: pos2(physical_x as f32 / ctx.pixels_per_point, f32::NAN), + advance_width: glyph_alloc.advance_width_px / ctx.pixels_per_point, + line_height: ctx.line_height, + font_face_height: face_metrics.row_height, + font_face_ascent: face_metrics.ascent, + font_height: ctx.font_metrics.row_height, + font_ascent: ctx.font_metrics.ascent, + uv_rect: glyph_alloc.uv_rect, + section_index: ctx.section_index, + first_vertex: 0, + }); + paragraph.cursor_x_px += glyph_alloc.advance_width_px; + } else { + let h_pos = paragraph.cursor_x_px + x_offset_px; + let y_offset_points = y_offset_px / ctx.pixels_per_point; + + let (glyph_alloc, physical_x) = + if let Some(ff) = font.fonts_by_id.get_mut(&run.font_key) { + ff.allocate_glyph_by_id( + font.atlas, face_metrics, glyph_id, x_advance_px, + h_pos, y_offset_points, is_cjk(chr), + ) + } else { + Default::default() + }; + + paragraph.glyphs.push(Glyph { + chr, + pos: pos2(physical_x as f32 / ctx.pixels_per_point, f32::NAN), + advance_width: x_advance_px / ctx.pixels_per_point, + line_height: ctx.line_height, + font_face_height: face_metrics.row_height, + font_face_ascent: face_metrics.ascent, + font_height: ctx.font_metrics.row_height, + font_ascent: ctx.font_metrics.ascent, + uv_rect: glyph_alloc.uv_rect, + section_index: ctx.section_index, + first_vertex: 0, + }); + paragraph.cursor_x_px += x_advance_px; + } + } +} + // Ignores the Y coordinate. fn layout_section( fonts: &mut FontsImpl, @@ -158,6 +285,8 @@ fn layout_section( byte_range, format, } = section; + + let mut shape_buffer = fonts.take_shape_buffer(); let mut font = fonts.font(&format.font_id.family); let font_size = format.font_id.size; let font_metrics = font.styled_metrics(pixels_per_point, font_size, &format.coords); @@ -169,76 +298,98 @@ fn layout_section( let mut paragraph = out_paragraphs.last_mut().unwrap(); if paragraph.glyphs.is_empty() { - paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? + paragraph.empty_paragraph_height = line_height; } - paragraph.cursor_x_px += leading_space * pixels_per_point; - let mut last_glyph_id = None; + let section_text = &job.text[byte_range.clone()]; + let mut ctx = ShapingContext { + pixels_per_point, + line_height, + extra_letter_spacing, + section_index, + font_metrics, + is_first_glyph_in_section: paragraph.glyphs.is_empty(), + prev_cluster: None, + }; + let mut runs = Vec::new(); - // Optimization: only recompute `ScaledMetrics` when the concrete `FontImpl` changes. - let mut current_font = FontFaceKey::INVALID; - let mut current_font_face_metrics = StyledMetrics::default(); - - for chr in job.text[byte_range.clone()].chars() { - if job.break_on_newline && chr == '\n' { + // Process each paragraph segment (split on newlines — the shaper can't handle them). + for (seg_idx, segment) in SplitOrWhole::new(section_text, job.break_on_newline).enumerate() { + if seg_idx > 0 { out_paragraphs.push(Paragraph::from_section_index(section_index)); paragraph = out_paragraphs.last_mut().unwrap(); - paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? - } else { - let (font_id, glyph_info) = font.glyph_info(chr); - let mut font_face = font.fonts_by_id.get_mut(&font_id); - if current_font != font_id { - current_font = font_id; - current_font_face_metrics = font_face - .as_ref() - .map(|font_face| { - font_face.styled_metrics(pixels_per_point, font_size, &format.coords) - }) - .unwrap_or_default(); - } + paragraph.empty_paragraph_height = line_height; + ctx.is_first_glyph_in_section = true; + } - if let (Some(font_face), Some(last_glyph_id), Some(glyph_id)) = - (&font_face, last_glyph_id, glyph_info.id) - { - paragraph.cursor_x_px += font_face.pair_kerning_pixels( - ¤t_font_face_metrics, - last_glyph_id, - glyph_id, - ); + if segment.is_empty() { + continue; + } - // Only apply extra_letter_spacing to glyphs after the first one: - paragraph.cursor_x_px += extra_letter_spacing * pixels_per_point; - } + font.segment_into_runs(segment, &mut runs); - let (glyph_alloc, physical_x) = if let Some(font_face) = font_face.as_mut() { - font_face.allocate_glyph( - font.atlas, - ¤t_font_face_metrics, - glyph_info, - chr, - paragraph.cursor_x_px, - ) - } else { - Default::default() + let num_runs = runs.len(); + for (run_idx, run) in runs.iter().enumerate() { + let run_text = &segment[run.byte_range.clone()]; + let Some(font_face) = font.fonts_by_id.get(&run.font_key) else { + continue; }; - paragraph.glyphs.push(Glyph { - chr, - pos: pos2(physical_x as f32 / pixels_per_point, f32::NAN), - advance_width: glyph_alloc.advance_width_px / pixels_per_point, - line_height, - font_face_height: current_font_face_metrics.row_height, - font_face_ascent: current_font_face_metrics.ascent, - font_height: font_metrics.row_height, - font_ascent: font_metrics.ascent, - uv_rect: glyph_alloc.uv_rect, - section_index, - first_vertex: 0, // filled in later - }); + let face_metrics = + font_face.styled_metrics(pixels_per_point, font_size, &format.coords); - paragraph.cursor_x_px += glyph_alloc.advance_width_px; - last_glyph_id = Some(glyph_alloc.id); + // Set buffer flags for paragraph boundary context. + let mut flags = harfrust::BufferFlags::empty(); + if seg_idx == 0 && run_idx == 0 { + flags |= harfrust::BufferFlags::BEGINNING_OF_TEXT; + } + if run_idx + 1 == num_runs { + flags |= harfrust::BufferFlags::END_OF_TEXT; + } + + let glyph_buffer = + font_face.shape_text(run_text, &format.coords, shape_buffer, flags); + + layout_shaped_run( + &mut font, run, run_text, &glyph_buffer, + &face_metrics, &mut ctx, paragraph, + ); + + shape_buffer = glyph_buffer.clear(); + } + } + + // Drop `font` to release the mutable borrow on `fonts` before recycling the buffer. + #[expect(clippy::drop_non_drop)] + drop(font); + fonts.return_shape_buffer(shape_buffer); +} + +/// Iterator that either splits on `'\n'` or yields the whole string once. +/// Avoids `Box` and `Vec<&str>` allocation. +enum SplitOrWhole<'a> { + Split(std::str::Split<'a, char>), + Whole(std::iter::Once<&'a str>), +} + +impl<'a> SplitOrWhole<'a> { + fn new(text: &'a str, split: bool) -> Self { + if split { + Self::Split(text.split('\n')) + } else { + Self::Whole(std::iter::once(text)) + } + } +} + +impl<'a> Iterator for SplitOrWhole<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option<&'a str> { + match self { + Self::Split(iter) => iter.next(), + Self::Whole(iter) => iter.next(), } } } @@ -1277,4 +1428,125 @@ mod tests { "Unexpected intrinsic size" ); } + + #[test] + fn test_combining_diacritics() { + // ɔ̃ = U+0254 (LATIN SMALL LETTER OPEN O) + U+0303 (COMBINING TILDE) + // With text shaping, the combining tilde should NOT produce a separate + // advance — it should be positioned above ɔ via GPOS anchors. + // Note: the default fonts don't contain U+0254, so the replacement glyph + // is used. The key test is that the combining mark does NOT add extra width. + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job_combined = LayoutJob::simple( + "ɔ\u{0303}".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley_combined = layout(&mut fonts, pixels_per_point, job_combined.into()); + + let job_base = LayoutJob::simple( + "ɔ".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley_base = layout(&mut fonts, pixels_per_point, job_base.into()); + + let width_combined = galley_combined.size().x; + let width_base = galley_base.size().x; + + assert!( + (width_combined - width_base).abs() < 2.0, + "Combining diacritic should not add significant width. \ + Base width: {width_base}, Combined width: {width_combined}" + ); + + let glyphs = &galley_combined.rows[0].row.glyphs; + assert!( + !glyphs.is_empty(), + "Expected at least 1 glyph for ɔ̃" + ); + } + + #[test] + fn test_shaping_basic_latin() { + // Basic test: shaped Latin text should produce the same number of glyphs as characters. + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job = LayoutJob::simple( + "Hello".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 1); + assert_eq!(galley.rows[0].row.glyphs.len(), 5); + assert!(galley.size().x > 0.0); + } + + #[test] + fn test_shaping_empty_string() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job = LayoutJob::simple( + String::new(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 1); + assert_eq!(galley.rows[0].row.glyphs.len(), 0); + } + + #[test] + fn test_shaping_multiple_newlines() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job = LayoutJob::simple( + "A\n\nB".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 3, "Expected 3 rows for 'A\\n\\nB'"); + assert_eq!(galley.rows[0].row.glyphs.len(), 1); // "A" + assert_eq!(galley.rows[1].row.glyphs.len(), 0); // empty line + assert_eq!(galley.rows[2].row.glyphs.len(), 1); // "B" + } + + #[test] + fn test_shaping_mixed_font_fallback() { + // Text with both Latin and emoji should work without panicking, + // even though they use different font faces. + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job = LayoutJob::simple( + "Hi 🎉 bye".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 1); + // "Hi " (3) + "🎉" (1) + " bye" (4) = at least 8 glyphs + assert!( + galley.rows[0].row.glyphs.len() >= 8, + "Expected >= 8 glyphs, got {}", + galley.rows[0].row.glyphs.len() + ); + } }