1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-31 05:40:03 -04:00

Integrate harfrust for text shaping (#8031)

* Related to #56 (Improve text — tracking issue)

## Summary

This PR integrates [harfrust](https://crates.io/crates/harfrust) (a
pure-Rust port of HarfBuzz) into epaint's text layout pipeline,
replacing the character-by-character glyph positioning with proper
OpenType text shaping.

### What this enables

- **GPOS kerning**: most modern fonts only ship kerning in GPOS tables
(not the legacy `kern` table). Pairs like "AV", "VA", "AT" are now
properly tightened.
- **GSUB substitutions**: ligatures (fi, fl), contextual alternates, and
other OpenType features.
- **Combining marks**: diacritics (e.g. ɔ̃) are positioned via anchor
tables instead of being rendered as standalone replacement glyphs.

### Before/After

#### Kerning, etc.

<img width="838" height="726" alt="before_main"
src="https://github.com/user-attachments/assets/f0f26d5f-b117-43a6-b39c-ea40d2e73836"
/>

<img width="838" height="726" alt="after_harfrust"
src="https://github.com/user-attachments/assets/d983e5da-486c-4f39-bd4f-5782a90c6b39"
/>

 #### Ligatures

<img width="1117" height="698" alt="before_closeup"
src="https://github.com/user-attachments/assets/7a3b08b4-cf6f-45b7-98ba-07c473cd3b02"
/>

<img width="1117" height="698" alt="after_closeup"
src="https://github.com/user-attachments/assets/6cfc5f21-d32f-4f09-be0c-59c8c553d44f"
/>

### Architecture

The shaping integrates into the existing pipeline without changing the
public API:

1. **`Font::segment_into_runs`** — segments text into contiguous runs by
font face (grapheme-cluster aware, never splits combining sequences)
2. **`FontFace::shape_text`** — calls harfrust to shape each run,
returning glyph IDs + positioned advances/offsets
3. **`layout_shaped_run`** — emits `Glyph` structs from the shaping
output, with NOTDEF fallback to other font faces for missing glyphs
4. **Buffer recycling** — `FontsImpl` pools a `harfrust::UnicodeBuffer`
to avoid per-layout allocations

### Disclaimer

I'm far from being a good Rust programmer. Claude Code did most of the
heavy lifting here. I did my best and used my limited knowledge to avoid
making too many mistakes. If this PR isn't up to quality standards,
please don't hesitate to close it.

## Test plan

- [x] `cargo test -p epaint` — all 18 text tests pass, including 6 new
ones
- [x] `cargo clippy -p epaint --all-features` — clean
- [x] `cargo fmt` — clean
- [ ] Snapshot tests need regeneration (expected: shaping changes glyph
positions)
- New tests added:
- `test_gpos_kerning` — verifies GPOS kerning tightens "AV", "VA", "AT"
pairs
- `test_combining_diacritics` — combining tilde doesn't add extra width
  - `test_shaping_basic_latin` — sanity check for Latin text
  - `test_shaping_empty_string` — empty input doesn't panic
  - `test_shaping_multiple_newlines` — newline splitting works correctly
  - `test_shaping_mixed_font_fallback` — Latin + emoji in same string

---------

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
Gautier Cailly
2026-04-06 14:25:04 +02:00
committed by GitHub
parent 33e89e33be
commit 16cad760a5
158 changed files with 998 additions and 502 deletions

View File

@@ -8,15 +8,35 @@ use crate::{
Color32, Mesh, Stroke, Vertex,
stroke::PathStroke,
text::{
font::{StyledMetrics, is_cjk, is_cjk_break_allowed},
TAB_SIZE,
font::{StyledMetrics, UvRect, is_cjk, is_cjk_break_allowed},
fonts::FontFaceKey,
},
};
use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row, RowVisuals};
use super::{
FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row, RowVisuals,
VariationCoords,
font::{Font, FontFace, ShapedGlyph},
};
// ----------------------------------------------------------------------------
/// 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 {
@@ -98,15 +118,21 @@ pub fn layout(fonts: &mut FontsImpl, pixels_per_point: f32, job: Arc<LayoutJob>)
// For most of this we ignore the y coordinate:
let mut paragraphs = vec![Paragraph::from_section_index(0)];
for (section_index, section) in job.sections.iter().enumerate() {
layout_section(
fonts,
pixels_per_point,
&job,
section_index as u32,
section,
&mut paragraphs,
);
{
let mut shape_buffer = fonts.take_shape_buffer();
for (section_index, section) in job.sections.iter().enumerate() {
let mut font = fonts.font(&section.format.font_id.family);
shape_buffer = layout_section(
&mut font,
shape_buffer,
pixels_per_point,
&job,
section_index as u32,
section,
&mut paragraphs,
);
}
fonts.return_shape_buffer(shape_buffer);
}
let point_scale = PointScale::new(pixels_per_point);
@@ -144,21 +170,198 @@ 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,
font_size: f32,
line_height: f32,
extra_letter_spacing: f32,
section_index: u32,
font_metrics: StyledMetrics,
is_first_glyph_in_section: bool,
prev_cluster: Option<u32>,
}
impl ShapingContext {
fn glyph(
&self,
chr: char,
physical_x: i32,
advance_width_px: f32,
face_metrics: &StyledMetrics,
uv_rect: UvRect,
) -> Glyph {
Glyph {
chr,
pos: pos2(physical_x as f32 / self.pixels_per_point, f32::NAN),
advance_width: advance_width_px / self.pixels_per_point,
line_height: self.line_height,
font_face_height: face_metrics.row_height,
font_face_ascent: face_metrics.ascent,
font_height: self.font_metrics.row_height,
font_ascent: self.font_metrics.ascent,
uv_rect,
section_index: self.section_index,
first_vertex: 0,
}
}
}
/// Produced by [`segment_into_runs`] for text shaping.
#[derive(Debug)]
struct TextRun {
/// Which font face should shape this run.
font_key: FontFaceKey,
/// Byte range within the section text.
byte_range: std::ops::Range<usize>,
}
/// 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;
// Reset cluster tracking — cluster values are byte offsets within run_text,
// so they are not comparable across runs.
ctx.prev_cluster = None;
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 mut advance_width_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}'); // Unicode Replacement Character
// Tab is a layout concept, not a glyph — the shaper doesn't know about tab stops.
// Override the advance width to TAB_SIZE × space width.
if chr == '\t' {
let (_, space_info) = font.glyph_info(' ');
let space_width_px = space_info.advance_width_unscaled.0 * px_scale;
advance_width_px = TAB_SIZE as f32 * space_width_px;
}
// 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);
let glyph = 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;
}
// Use the fallback font face (not run.font_key which returned NOTDEF).
let (fallback_key, glyph_info) = font.glyph_info(chr);
let fallback_metrics = font
.fonts_by_id
.get(&fallback_key)
.map(|ff| {
ff.styled_metrics(ctx.pixels_per_point, ctx.font_size, &Default::default())
})
.unwrap_or_default();
let advance_width_px =
glyph_info.advance_width_unscaled.0 * fallback_metrics.px_scale_factor;
let (glyph_alloc, physical_x) =
if let Some(ff) = font.fonts_by_id.get_mut(&fallback_key) {
ff.allocate_glyph(
font.atlas,
&fallback_metrics,
&ShapedGlyph {
glyph_id: glyph_info.id.unwrap_or(skrifa::GlyphId::NOTDEF),
h_pos: paragraph.cursor_x_px,
is_cjk: is_cjk(chr),
},
)
} else {
Default::default()
};
paragraph.cursor_x_px += advance_width_px;
ctx.glyph(
chr,
physical_x,
advance_width_px,
&fallback_metrics,
glyph_alloc.uv_rect,
)
} else {
let (mut glyph_alloc, physical_x) =
if let Some(ff) = font.fonts_by_id.get_mut(&run.font_key) {
ff.allocate_glyph(
font.atlas,
face_metrics,
&ShapedGlyph {
glyph_id,
h_pos: paragraph.cursor_x_px + x_offset_px,
is_cjk: is_cjk(chr),
},
)
} else {
Default::default()
};
// Apply shaper y_offset — this varies per glyph instance so it
// is not part of the cached ShapedGlyph / GlyphAllocation.
glyph_alloc.uv_rect.offset.y += y_offset_px / ctx.pixels_per_point;
paragraph.cursor_x_px += advance_width_px;
ctx.glyph(
chr,
physical_x,
advance_width_px,
face_metrics,
glyph_alloc.uv_rect,
)
};
paragraph.glyphs.push(glyph);
}
}
// Ignores the Y coordinate.
#[must_use]
fn layout_section(
fonts: &mut FontsImpl,
font: &mut Font<'_>,
mut shape_buffer: harfrust::UnicodeBuffer,
pixels_per_point: f32,
job: &LayoutJob,
section_index: u32,
section: &LayoutSection,
out_paragraphs: &mut Vec<Paragraph>,
) {
) -> harfrust::UnicodeBuffer {
let LayoutSection {
leading_space,
byte_range,
format,
} = section;
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);
let line_height = section
@@ -169,76 +372,100 @@ 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,
font_size,
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 0 < seg_idx {
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;
}
segment_into_runs(font, 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 run_idx == 0 {
flags |= harfrust::BufferFlags::BEGINNING_OF_TEXT;
}
if run_idx + 1 == num_runs {
flags |= harfrust::BufferFlags::END_OF_TEXT;
}
let glyph_buffer = shape_text(font_face, run_text, &format.coords, shape_buffer, flags);
layout_shaped_run(
font,
run,
run_text,
&glyph_buffer,
&face_metrics,
&mut ctx,
paragraph,
);
shape_buffer = glyph_buffer.clear();
}
}
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(),
}
}
}
@@ -479,33 +706,14 @@ fn replace_last_glyph_with_overflow_character(
.unwrap_or_default();
let overflow_glyph_x = if let Some(prev_glyph) = row.glyphs.last() {
// Kern the overflow character properly
let pair_kerning = font_face
.as_mut()
.map(|font_face| {
if let (Some(prev_glyph_id), Some(overflow_glyph_id)) = (
font_face.glyph_info(prev_glyph.chr).and_then(|g| g.id),
font_face.glyph_info(overflow_character).and_then(|g| g.id),
) {
font_face.pair_kerning(&font_face_metrics, prev_glyph_id, overflow_glyph_id)
} else {
0.0
}
})
.unwrap_or_default();
prev_glyph.max_x() + extra_letter_spacing + pair_kerning
prev_glyph.max_x() + extra_letter_spacing
} else {
0.0 // TODO(emilk): heed paragraph leading_space 😬
};
let replacement_glyph_width = font_face
.as_mut()
.and_then(|f| f.glyph_info(overflow_character))
.map(|i| {
i.advance_width_unscaled.0 * font_face_metrics.px_scale_factor / pixels_per_point
})
.unwrap_or_default();
let advance_width_px =
glyph_info.advance_width_unscaled.0 * font_face_metrics.px_scale_factor;
let replacement_glyph_width = advance_width_px / pixels_per_point;
// Check if we're within width budget:
if overflow_glyph_x + replacement_glyph_width <= job.effective_wrap_width()
@@ -519,9 +727,11 @@ fn replace_last_glyph_with_overflow_character(
f.allocate_glyph(
font.atlas,
&font_face_metrics,
glyph_info,
overflow_character,
overflow_glyph_x * pixels_per_point,
&ShapedGlyph {
glyph_id: glyph_info.id.unwrap_or(skrifa::GlyphId::NOTDEF),
h_pos: overflow_glyph_x * pixels_per_point,
is_cjk: is_cjk(overflow_character),
},
)
})
.unwrap_or_default();
@@ -536,7 +746,7 @@ fn replace_last_glyph_with_overflow_character(
row.glyphs.push(Glyph {
chr: overflow_character,
pos: pos2(physical_x as f32 / pixels_per_point, f32::NAN),
advance_width: replacement_glyph_alloc.advance_width_px / pixels_per_point,
advance_width: advance_width_px / pixels_per_point,
line_height,
font_face_height: font_face_metrics.row_height,
font_face_ascent: font_face_metrics.ascent,
@@ -1060,6 +1270,90 @@ impl RowBreakCandidates {
// ----------------------------------------------------------------------------
/// 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).
///
/// NOTE: Segmentation is by font face, not by Unicode script. A run may
/// mix scripts (e.g. Latin + Cyrillic) when they share the same font.
/// This is acceptable for scripts with similar shaping rules, but would
/// need script-aware splitting once RTL/bidi support is added.
///
/// Results are appended to `out` (which is cleared first) to allow
/// the caller to reuse the allocation across calls.
fn segment_into_runs(font: &mut Font<'_>, 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, _) = font.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,
});
}
}
/// 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()`.
fn shape_text(
font_face: &FontFace,
text: &str,
coords: &VariationCoords,
mut buffer: harfrust::UnicodeBuffer,
flags: harfrust::BufferFlags,
) -> harfrust::GlyphBuffer {
let font_ref = font_face.skrifa_font_ref();
let tweak = font_face.tweak();
// Build shaper with variable font instance if variation coordinates are set.
let variations: Vec<harfrust::Variation> = 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 = font_face
.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, &[])
}
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
@@ -1277,4 +1571,177 @@ 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()
);
}
#[test]
fn test_gpos_kerning() {
// GPOS kerning: pairs like "AV", "VA", "AT" should be tighter than
// the sum of individual character widths. Without text shaping, egui
// only uses the legacy `kern` table, so these pairs had diff ≈ 0.
// With harfrust, GPOS kerning applies proper negative adjustments.
let pixels_per_point = 1.0;
let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default());
let font_id = FontId::proportional(14.0);
for pair in ["AV", "VA", "AT"] {
let (pair_w, _, _) = measure_text(&mut fonts, pair, &font_id, pixels_per_point);
let chars: Vec<char> = pair.chars().collect();
let (w1, _, _) = measure_text(
&mut fonts,
&chars[0].to_string(),
&font_id,
pixels_per_point,
);
let (w2, _, _) = measure_text(
&mut fonts,
&chars[1].to_string(),
&font_id,
pixels_per_point,
);
let sum = w1 + w2;
let kern_adjustment = sum - pair_w;
assert!(
kern_adjustment > 0.5,
"GPOS kerning for '{pair}': expected pair to be noticeably tighter \
than sum of individuals. pair_width={pair_w:.2}, sum={sum:.2}, \
kern_adjustment={kern_adjustment:.2} (should be > 0.5)",
);
}
}
fn measure_text(
fonts: &mut FontsImpl,
text: &str,
font_id: &FontId,
pixels_per_point: f32,
) -> (f32, usize, Vec<(char, f32)>) {
let job = LayoutJob::simple(
text.to_owned(),
font_id.clone(),
Color32::WHITE,
f32::INFINITY,
);
let galley = layout(fonts, pixels_per_point, job.into());
let glyphs = &galley.rows[0].row.glyphs;
let details: Vec<_> = glyphs.iter().map(|g| (g.chr, g.advance_width)).collect();
(galley.size().x, glyphs.len(), details)
}
}