diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index d0e624f98..b32f2fb4c 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -3,7 +3,7 @@ use emath::Align; use epaint::{ CornerRadius, FontColorTransferFunction, Shadow, Stroke, TextOptions, - text::{FontTweak, FontVariationAxis}, + text::{FontTweak, FontVariationAxis, HintingTarget, SmoothHinting}, }; use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; @@ -2902,6 +2902,7 @@ pub fn font_tweak_ui(ui: &mut Ui, tweak: &mut FontTweak, axes: &[FontVariationAx y_offset_factor, y_offset, hinting, + hinting_target, coords, thin_space_width, tab_size, @@ -2929,6 +2930,59 @@ pub fn font_tweak_ui(ui: &mut Ui, tweak: &mut FontTweak, axes: &[FontVariationAx }); ui.end_row(); + ui.label("hinting_target") + .on_hover_text("How aggressively to snap glyph outlines to the pixel grid. Only matters when hinting is enabled."); + ui.vertical(|ui| { + ui.horizontal(|ui| { + let is_mono = matches!(hinting_target, HintingTarget::Mono); + if ui + .radio(!is_mono, "Smooth") + .on_hover_text("Hinting tuned for anti-aliased rendering. The normal choice.") + .clicked() + && is_mono + { + *hinting_target = HintingTarget::default(); + } + if ui + .radio(is_mono, "Mono") + .on_hover_text( + "Strongest hinting (designed for 1-bit rendering). Sharpest, but \ + distorts glyph weight across sizes.", + ) + .clicked() + { + *hinting_target = HintingTarget::Mono; + } + if ui + .button("Reset") + .on_hover_text("Reset the hinting target to its default.") + .clicked() + { + *hinting_target = HintingTarget::default(); + } + }); + if let HintingTarget::Smooth(SmoothHinting { + light, + symmetric_rendering, + preserve_linear_metrics, + }) = hinting_target + { + ui.checkbox(light, "light").on_hover_text( + "Hint only vertically, preserving the font's horizontal proportions \ + (softer). Off also fits horizontally.", + ); + ui.checkbox(symmetric_rendering, "symmetric_rendering").on_hover_text( + "Render glyphs the same regardless of sub-pixel position (good for \ + caching/animation), but can blur stems. Only affects interpreter-hinted fonts.", + ); + ui.checkbox(preserve_linear_metrics, "preserve_linear_metrics").on_hover_text( + "Keep spacing independent of hinting. Off lets the hinter snap \ + horizontally for crisper vertical stems on low-dpi screens.", + ); + } + }); + ui.end_row(); + ui.label("subpixel_binning"); ui.horizontal(|ui| { ui.radio_value(subpixel_binning, Some(true), "on"); diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index 987f271e0..c04c619f8 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -225,6 +225,7 @@ impl FontCell { glyph_id: GlyphId, bin: SubpixelBin, location: skrifa::instance::LocationRef<'_>, + hinting_target: skrifa::outline::Target, ) -> Option { debug_assert!( glyph_id != skrifa::GlyphId::NOTDEF, @@ -244,18 +245,10 @@ impl FontCell { let size = skrifa::instance::Size::new(metrics.scale); if hinting_instance.size() != size || hinting_instance.location().coords() != location.coords() + || hinting_instance.target() != hinting_target { hinting_instance - .reconfigure( - &font_data.outline_glyphs, - size, - location, - skrifa::outline::Target::Smooth { - mode: skrifa::outline::SmoothMode::Normal, - symmetric_rendering: true, - preserve_linear_metrics: true, - }, - ) + .reconfigure(&font_data.outline_glyphs, size, location, hinting_target) .ok()?; } let draw_settings = skrifa::outline::DrawSettings::hinted(hinting_instance, false); @@ -637,9 +630,17 @@ impl FontFace { let cache_key = GlyphCacheKey::new(glyph_id, metrics, bin); + let hinting_target = self.tweak.hinting_target.into(); 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()) + .allocate_glyph_uncached( + atlas, + metrics, + glyph_id, + bin, + (&metrics.location).into(), + hinting_target, + ) .unwrap_or_default() }); diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 7c5f69988..62b1b83a0 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -242,6 +242,11 @@ pub struct FontTweak { /// `None` means use the global setting in [`TextOptions::font_hinting`]. pub hinting: Option, + /// How to grid-fit the glyph outlines when hinting is enabled. + /// + /// Has no effect when hinting is disabled (see [`Self::hinting`]). + pub hinting_target: HintingTarget, + /// Override the global sub-pixel binning setting for this specific font. /// /// `None` means use the global setting in [`TextOptions::subpixel_binning`]. @@ -271,6 +276,7 @@ impl Default for FontTweak { y_offset_factor: 0.0, y_offset: 0.0, hinting: None, + hinting_target: HintingTarget::default(), subpixel_binning: None, coords: VariationCoords::default(), thin_space_width: 0.5, @@ -281,6 +287,111 @@ impl Default for FontTweak { // ---------------------------------------------------------------------------- +/// How to *hint* glyph outlines, i.e. how aggressively to nudge them onto the +/// pixel grid before rasterizing. Mirrors [`skrifa::outline::Target`]. +/// +/// Hinting trades shape fidelity for sharpness: snapping stems to whole pixels +/// makes text crisp at small sizes / low dpi, at the cost of slightly distorting +/// the designer's outlines. At high dpi it matters little. +/// +/// This only has an effect if the font is actually hinted — either it ships +/// TrueType instructions, or it was auto-hinted (see [`FontTweak::hinting`]). +/// +/// Used by [`FontTweak::hinting_target`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum HintingTarget { + /// Strongest hinting, designed for aliased 1-bit (black & white) rendering. + /// + /// Snaps stems hard to the pixel grid for maximum sharpness. egui always + /// renders anti-aliased, so in practice this looks much like [`Self::Smooth`] + /// here; it mostly exists for completeness. Maps to `skrifa`'s `Target::Mono`. + Mono, + + /// Hinting tuned for anti-aliased rendering. This is what you normally want, + /// and what egui uses by default. Maps to `skrifa`'s `Target::Smooth`. + Smooth(SmoothHinting), +} + +impl Default for HintingTarget { + fn default() -> Self { + Self::Smooth(SmoothHinting::default()) + } +} + +impl From for skrifa::outline::Target { + fn from(hinting_target: HintingTarget) -> Self { + use skrifa::outline::SmoothMode; + match hinting_target { + HintingTarget::Mono => Self::Mono, + HintingTarget::Smooth(SmoothHinting { + light, + symmetric_rendering, + preserve_linear_metrics, + }) => Self::Smooth { + mode: if light { + SmoothMode::Light + } else { + SmoothMode::Normal + }, + symmetric_rendering, + preserve_linear_metrics, + }, + } + } +} + +/// Tuning for [`HintingTarget::Smooth`], mirroring `skrifa`'s `Target::Smooth`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct SmoothHinting { + /// Hint only lightly: snap stems vertically but leave horizontal shapes + /// alone (`FreeType`'s "light" mode). Preserves the font's proportions + /// better, at the cost of a little horizontal sharpness. + /// + /// `false` uses the "normal" mode, which also fits horizontally. + /// Maps to `SmoothMode::Light` (`true`) vs `SmoothMode::Normal` (`false`). + pub light: bool, + + /// Render a glyph the same way regardless of its sub-pixel position. + /// + /// `true` makes glyphs position-independent (better for caching and + /// animation), but a font's instructions may then widen stems and look + /// slightly blurrier under an analytic rasterizer like egui's. + /// + /// **Only affects fonts hinted via the TrueType interpreter** (i.e. fonts + /// that ship their own instructions). It has no effect on the auto-hinter. + /// Mirrors `Target::Smooth { symmetric_rendering }`. + pub symmetric_rendering: bool, + + /// Keep advance widths independent of hinting (don't grid-fit horizontally + /// in a way that changes spacing). + /// + /// `true` keeps inter-glyph spacing identical to the unhinted font, so + /// layout never depends on hinting — but it also prevents horizontal + /// grid-fitting, leaving vertical stems softer on low-dpi screens. + /// + /// `false` lets the (auto)hinter snap horizontally for crisper stems. + /// egui positions glyphs from the shaper's advances, not the hinted + /// outline, so this mainly affects sharpness here, not layout. + /// Mirrors `Target::Smooth { preserve_linear_metrics }`. + pub preserve_linear_metrics: bool, +} + +impl Default for SmoothHinting { + fn default() -> Self { + // Matches the behavior egui had before the hinting target was configurable. + // Note this means horizontal grid-fitting is opt-in (see `preserve_linear_metrics`). + Self { + light: false, + symmetric_rendering: true, + preserve_linear_metrics: true, + } + } +} + +// ---------------------------------------------------------------------------- + pub type Blob = Arc + Send + Sync>; fn blob_from_font_data(data: &FontData) -> Blob { diff --git a/crates/epaint/src/text/mod.rs b/crates/epaint/src/text/mod.rs index 90f13a4ce..9bae95af1 100644 --- a/crates/epaint/src/text/mod.rs +++ b/crates/epaint/src/text/mod.rs @@ -10,7 +10,8 @@ mod text_layout_types; pub use { fonts::{ FontData, FontDefinitions, FontFamily, FontId, FontInsert, FontPriority, FontTweak, - FontVariationAxis, Fonts, FontsImpl, FontsView, InsertFontFamily, + FontVariationAxis, Fonts, FontsImpl, FontsView, HintingTarget, InsertFontFamily, + SmoothHinting, }, index::{ByteIndex, ByteRange, ByteRangeExt, CharIndex, CharRange, CharRangeExt}, text_layout::*,