1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

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.
This commit is contained in:
gcailly
2026-03-25 16:00:59 +01:00
parent 4feac890aa
commit 6b60ca1353
6 changed files with 532 additions and 63 deletions

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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

View File

@@ -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<char, GlyphInfo>,
glyph_alloc_cache: ahash::HashMap<GlyphCacheKey, GlyphAllocation>,
}
@@ -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<harfrust::Variation> = 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<usize>,
}
// 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<TextRun>) {
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) {

View File

@@ -765,6 +765,9 @@ pub struct FontsImpl {
fonts_by_id: nohash_hasher::IntMap<FontFaceKey, FontFace>,
fonts_by_name: ahash::HashMap<String, FontFaceKey>,
family_cache: ahash::HashMap<FontFamily, CachedFamily>,
/// Recycled `harfrust` shaping buffer to avoid per-layout allocations.
shape_buffer: Option<harfrust::UnicodeBuffer>,
}
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(|| {

View File

@@ -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<LayoutJob>)
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<u32>,
}
/// 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(
&current_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,
&current_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<dyn Iterator>` 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()
);
}
}