Skip to content

Integrate harfrust for text shaping#8031

Open
gcailly wants to merge 37 commits intoemilk:mainfrom
gcailly:feat/harfrust-text-shaping
Open

Integrate harfrust for text shaping#8031
gcailly wants to merge 37 commits intoemilk:mainfrom
gcailly:feat/harfrust-text-shaping

Conversation

@gcailly
Copy link
Copy Markdown
Contributor

@gcailly gcailly commented Mar 26, 2026

Summary

This PR integrates 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.

before_main after_harfrust

Ligatures

before_closeup after_closeup

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

  • cargo test -p epaint — all 18 text tests pass, including 6 new ones
  • cargo clippy -p epaint --all-features — clean
  • 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

gcailly added 6 commits March 25, 2026 16:00
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 emilk#2517.
Verify that letter pairs like AV, VA, AT are measurably tighter
when laid out together than the sum of their individual widths.
This test fails without harfrust text shaping (kern adjustment ≈ 0)
and passes with it (kern adjustment > 0.5px).
Remove copy-pasted docstring on segment_into_runs, and set
BEGINNING_OF_TEXT on the first run of every paragraph segment
(not just the first segment) to match END_OF_TEXT behavior.
…between runs

Two bugs in layout_shaped_run:

1. When the shaper returned NOTDEF, glyph_info was resolved via font
   fallback but the returned FontFaceKey was discarded. The glyph was
   then allocated with run.font_key (the face that couldn't render it),
   causing the glyph to silently render as invisible. Now uses the
   correct fallback font face and its metrics for both allocation and
   Glyph font_face_height/font_face_ascent.

2. prev_cluster was not reset between runs. Since harfrust cluster
   values are byte offsets within each run's text, comparing clusters
   across runs is semantically wrong and could skip extra_letter_spacing
   at run boundaries (e.g. when both runs start at cluster 0).
…o_runs

- Update GlyphAllocation.id docstring to reflect its actual usage
  (overflow character kerning via legacy kern table)
- Replace planning artifact comment on shaper y_offset caching
- Document script-mixing limitation in segment_into_runs
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 26, 2026

Preview available at https://egui-pr-preview.github.io/pr/8031-featharfrust-text-shaping
Note that it might take a couple seconds for the update to show up after the preview_build workflow has completed.

View snapshot changes at kitdiff

gcailly added 2 commits March 26, 2026 16:11
Text shaping changes glyph positioning, which affects rendered output.
Snapshots regenerated from macOS CI run #23601039294.
@gcailly gcailly changed the title Integrate harfrust for OpenType text shaping Integrate harfrust for text shaping Mar 26, 2026
gcailly added 16 commits March 26, 2026 16:29
Regenerated from macOS CI run #23602213577.
56 snapshots updated to reflect both text shaping changes
and v0.34.0 visual updates (scroll fade, etc.).
These were incorrectly overwritten by commit 50d820c.
Restored to the correct CI-generated versions.
These were never updated on our branch. Text shaping changes
the rendered output for text-heavy side containers.
Local UPDATE_SNAPSHOTS=1 confirms only sides/ and rotated/
snapshots needed updating — layout/, visuals/ etc. pass as-is.
Text shaping changes widget widths by ~1px on macOS,
causing image size mismatches (e.g. 850->849px).
24 snapshots updated from CI run artifacts.
These will differ from macOS CI — the next CI run will produce
.new.png for ALL of them at once, allowing a single bulk fix.
@gcailly gcailly marked this pull request as ready for review March 26, 2026 22:05
@gcailly gcailly requested a review from lucasmerlin as a code owner March 26, 2026 22:05
@emilk
Copy link
Copy Markdown
Owner

emilk commented Mar 27, 2026

Wow, very cool! I haven't looked at the code yet but:

  • I can see on kitdiff that the image_kerning test have regressed: thin spaces are now too thick, and \t doesn't seem to work at all
  • I would it if you added some new snapshot tests with the images in your PR description
  • I wonder what @valadaptive thinks :)

@emilk emilk added the text Problems related to text label Mar 27, 2026
@emilk emilk added visuals Renderings / graphics releated egui epaint style visuals and theming and removed visuals Renderings / graphics releated labels Mar 27, 2026
@gcailly
Copy link
Copy Markdown
Contributor Author

gcailly commented Mar 27, 2026

  • I can see on kitdiff that the image_kerning test have regressed: thin spaces are now too thick, and \t doesn't seem to work at all

The previous code had a custom override for U+2009 (thin space) that clamped the advance width to min(em/6, space_width * 0.5). With harfrust, the advance width now comes directly from the font's own metrics for the thin space glyph. I think this is actually more correct. If the font defines a thin space with a specific width, egui should respect it rather than override it. What do you think ? Should we keep the old behavior or trust the font ?

As for \t, I agree with you : tab stops are a layout-level concept that no shaper handles. I'll add a post-shaping override to restore the TAB_SIZE * space_width behavior.

  • I would it if you added some new snapshot tests with the images in your PR description

About adding new snapshot tests for the shaping demos (ligatures, kerning), there's a difficulty : the bundled Ubuntu font doesn't have visible ligatures. Would it be acceptable to embed a test-only font in the test fixtures to demonstrate these features ?

I saw that @valadaptive put a lot of work into the Parley integration and concluded it wasn't viable. I hope she finds this approach correct. Moreover, I'm a little embarrassed to be using AI without fully understanding what I'm doing, even though I have good intentions and take many precautions to do things correctly. I hope I'm not wasting your time.

gcailly added 2 commits March 27, 2026 12:23
The text shaper doesn't handle tab stops — override the advance width
to TAB_SIZE × space width in layout_shaped_run, matching the previous
character-by-character behavior.
Replace 5 loose parameters with a ShapedGlyph struct, removing the
need for #[expect(clippy::too_many_arguments)].
@oscargus
Copy link
Copy Markdown
Contributor

Can you possibly consider updating skrifa and vello_cpu as well? These will change some of the test images and it may be just as well to do all in one go...

Update vello_cpu to 0.0.7, which produces slightly different
rasterization output. All snapshot tests have been regenerated.

Note: skrifa cannot be bumped to 0.41.0 in this PR because it
pulls in read-fonts 0.38, while harfrust 0.5.2 depends on
read-fonts 0.37. The two FontRef types are incompatible.
@gcailly
Copy link
Copy Markdown
Contributor Author

gcailly commented Mar 28, 2026

Thanks for the suggestion! I've bumped vello_cpu from 0.0.6 to 0.0.7 and updated all snapshots (141 files).

However, skrifa cannot be bumped to 0.41.0 in this PR: skrifa 0.41 pulls in read-fonts 0.38, while the published harfrust 0.5.2 depends on read-fonts 0.37. Since this PR passes skrifa's FontRef directly to harfrust's shaping API, the two FontRef types (from different read-fonts versions) are incompatible.

Note: harfrust's main branch has already moved to read-fonts 0.38, so this will unblock once a new harfrust version is published.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

egui epaint style visuals and theming text Problems related to text

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants