1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-01 06:10:06 -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

@@ -2,10 +2,7 @@
use emath::{GuiRounding as _, OrderedFloat, Vec2, vec2};
use self_cell::self_cell;
use skrifa::{
MetadataProvider as _,
raw::{TableProvider as _, tables::kern::SubtableKind},
};
use skrifa::{GlyphId, MetadataProvider as _};
use std::collections::BTreeMap;
use vello_cpu::{color, kurbo};
@@ -44,12 +41,10 @@ impl UvRect {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct GlyphInfo {
/// Used for pair-kerning.
///
/// Doesn't need to be unique.
///
/// Is `None` for a special "invisible" glyph.
pub(crate) id: Option<skrifa::GlyphId>,
pub(crate) id: Option<GlyphId>,
/// In [`skrifa`]s "unscaled" coordinate system.
pub advance_width_unscaled: OrderedFloat<f32>,
@@ -124,17 +119,8 @@ impl SubpixelBin {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GlyphAllocation {
/// Used for pair-kerning.
///
/// Doesn't need to be unique.
/// Use [`skrifa::GlyphId::NOTDEF`] if you just want to have an id, and don't care.
pub(crate) id: skrifa::GlyphId,
/// Unit: screen pixels.
pub advance_width_px: f32,
/// UV rectangle for drawing.
pub uv_rect: UvRect,
}
@@ -145,7 +131,7 @@ struct GlyphCacheKey(u64);
impl nohash_hasher::IsEnabled for GlyphCacheKey {}
impl GlyphCacheKey {
fn new(glyph_id: skrifa::GlyphId, metrics: &StyledMetrics, bin: SubpixelBin) -> Self {
fn new(glyph_id: GlyphId, metrics: &StyledMetrics, bin: SubpixelBin) -> Self {
let StyledMetrics {
pixels_per_point,
px_scale_factor,
@@ -198,12 +184,10 @@ impl FontCell {
&mut self,
atlas: &mut TextureAtlas,
metrics: &StyledMetrics,
glyph_info: &GlyphInfo,
glyph_id: GlyphId,
bin: SubpixelBin,
location: skrifa::instance::LocationRef<'_>,
) -> Option<GlyphAllocation> {
let glyph_id = glyph_info.id?;
debug_assert!(
glyph_id != skrifa::GlyphId::NOTDEF,
"Can't allocate glyph for id 0"
@@ -288,11 +272,7 @@ impl FontCell {
}
};
Some(GlyphAllocation {
id: glyph_id,
advance_width_px: glyph_info.advance_width_unscaled.0 * metrics.px_scale_factor,
uv_rect,
})
Some(GlyphAllocation { uv_rect })
}
}
@@ -337,6 +317,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 +377,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(),
})
@@ -483,7 +470,7 @@ impl FontFace {
let glyph_id = font_data
.charmap
.map(c)
.filter(|id| *id != skrifa::GlyphId::NOTDEF)?;
.filter(|id| *id != GlyphId::NOTDEF)?;
let glyph_info = GlyphInfo {
id: Some(glyph_id),
@@ -497,38 +484,6 @@ impl FontFace {
Some(glyph_info)
}
#[inline]
pub(super) fn pair_kerning_pixels(
&self,
metrics: &StyledMetrics,
last_glyph_id: skrifa::GlyphId,
glyph_id: skrifa::GlyphId,
) -> f32 {
let skrifa_font = &self.font.borrow_dependent().skrifa;
let Ok(kern) = skrifa_font.kern() else {
return 0.0;
};
kern.subtables()
.find_map(|st| match st.ok()?.kind().ok()? {
SubtableKind::Format0(table_ref) => table_ref.kerning(last_glyph_id, glyph_id),
SubtableKind::Format1(_) => None,
SubtableKind::Format2(subtable2) => subtable2.kerning(last_glyph_id, glyph_id),
SubtableKind::Format3(table_ref) => table_ref.kerning(last_glyph_id, glyph_id),
})
.unwrap_or_default() as f32
* metrics.px_scale_factor
}
#[inline]
pub fn pair_kerning(
&self,
metrics: &StyledMetrics,
last_glyph_id: skrifa::GlyphId,
glyph_id: skrifa::GlyphId,
) -> f32 {
self.pair_kerning_pixels(metrics, last_glyph_id, glyph_id) / metrics.pixels_per_point
}
#[inline(always)]
pub fn styled_metrics(
&self,
@@ -571,51 +526,67 @@ impl FontFace {
}
}
pub(crate) fn skrifa_font_ref(&self) -> &skrifa::FontRef<'_> {
&self.font.borrow_dependent().skrifa
}
pub(crate) fn tweak(&self) -> &FontTweak {
&self.tweak
}
pub(crate) fn shaper_data(&self) -> &harfrust::ShaperData {
&self.shaper_data
}
pub fn allocate_glyph(
&mut self,
atlas: &mut TextureAtlas,
metrics: &StyledMetrics,
glyph_info: GlyphInfo,
chr: char,
h_pos: f32,
shaped: &ShapedGlyph,
) -> (GlyphAllocation, i32) {
let advance_width_px = glyph_info.advance_width_unscaled.0 * metrics.px_scale_factor;
let ShapedGlyph {
glyph_id,
h_pos,
is_cjk,
} = *shaped;
let Some(glyph_id) = glyph_info.id else {
// Invisible.
return (GlyphAllocation::default(), h_pos as i32);
};
if glyph_id == GlyphId::NOTDEF {
// invisible
return (GlyphAllocation::default(), h_pos.round() as i32);
}
// CJK scripts contain a lot of characters and could hog the glyph atlas if we stored 4 subpixel offsets per
// glyph.
let (h_pos_round, bin) = if is_cjk(chr) {
let (h_pos_round, bin) = if is_cjk {
// CJK scripts contain a lot of characters and could hog the glyph atlas
// if we stored 4 subpixel offsets per glyph.
(h_pos.round() as i32, SubpixelBin::Zero)
} else {
SubpixelBin::new(h_pos)
};
let entry = match self
.glyph_alloc_cache
.entry(GlyphCacheKey::new(glyph_id, metrics, bin))
{
std::collections::hash_map::Entry::Occupied(glyph_alloc) => {
let mut glyph_alloc = *glyph_alloc.get();
glyph_alloc.advance_width_px = advance_width_px; // Hack to get `\t` and thin space to work, since they use the same glyph id as ` ` (space).
return (glyph_alloc, h_pos_round);
}
std::collections::hash_map::Entry::Vacant(entry) => entry,
};
let cache_key = GlyphCacheKey::new(glyph_id, metrics, bin);
let allocation = self
.font
.allocate_glyph_uncached(atlas, metrics, &glyph_info, bin, (&metrics.location).into())
.unwrap_or_default();
let alloc = *self.glyph_alloc_cache.entry(cache_key).or_insert_with(|| {
self.font
.allocate_glyph_uncached(atlas, metrics, glyph_id, bin, (&metrics.location).into())
.unwrap_or_default()
});
entry.insert(allocation);
(allocation, h_pos_round)
(alloc, h_pos_round)
}
}
/// Positioning info for a single glyph, ready for atlas allocation.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ShapedGlyph {
pub glyph_id: GlyphId,
/// Horizontal position of the glyph origin, in physical pixels.
pub h_pos: f32,
/// CJK glyphs skip subpixel positioning to save atlas space.
pub is_cjk: bool,
}
// TODO(emilk): rename?
/// Wrapper over multiple [`FontFace`] (e.g. a primary + fallbacks for emojis)
pub struct Font<'a> {