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() + ); + } }