diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1567,7 +1567,7 @@ dependencies = [
  "ecolor",
  "emath",
  "epaint_default_fonts",
- "font-types 0.12.6",
+ "font-types 0.12.7",
  "harfrust 0.12.0",
  "log",
  "nohash-hasher",
@@ -1793,9 +1793,9 @@ dependencies = [
 
 [[package]]
 name = "font-types"
-version = "0.12.6"
+version = "0.12.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
+checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
 dependencies = [
  "bytemuck",
 ]
@@ -2170,15 +2170,15 @@ dependencies = [
 
 [[package]]
 name = "harfrust"
-version = "0.13.3"
+version = "0.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6"
+checksum = "865dcc4105d51c323d8fedbb2d75b5e9c8ecddf5613f74c82f851d0afbc97114"
 dependencies = [
  "bitflags 2.13.2",
  "bytemuck",
- "once_cell",
- "read-fonts 0.43.3",
+ "read-fonts 0.46.0",
  "smallvec",
+ "yoke",
 ]
 
 [[package]]
@@ -4192,30 +4192,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
 dependencies = [
  "bytemuck",
- "font-types 0.12.6",
+ "font-types 0.12.7",
  "once_cell",
 ]
 
 [[package]]
 name = "read-fonts"
-version = "0.43.3"
+version = "0.44.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea"
+checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
 dependencies = [
  "bytemuck",
- "font-types 0.12.6",
+ "font-types 0.12.7",
  "once_cell",
 ]
 
 [[package]]
 name = "read-fonts"
-version = "0.44.0"
+version = "0.46.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
+checksum = "bdaa4fffe9ff3ca2c353fa9729792805b9b3e324695ff5a081f860612e81b330"
 dependencies = [
  "bytemuck",
- "font-types 0.12.6",
+ "foldhash 0.2.0",
+ "font-types 0.12.7",
  "once_cell",
+ "smallvec",
+ "yoke",
 ]
 
 [[package]]
@@ -6788,7 +6791,7 @@ name = "wordcraft-fonts"
 version = "0.11.0"
 dependencies = [
  "brotli-decompressor",
- "harfrust 0.13.3",
+ "harfrust 0.15.0",
  "kurbo",
  "log",
  "skrifa 0.47.0",
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -48,7 +48,7 @@ regex-automata = { version = "0.4", default-features = false, features = ["std",
 
 # text / rendering / formats
 skrifa = "0.47"
-harfrust = "0.13"
+harfrust = "0.15"
 # WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders
 # (brotli-decompressor, flate2) are already in the tree.
 wuff = "0.2.9"
diff --git a/crates/fonts/src/fontdb/face.rs b/crates/fonts/src/fontdb/face.rs
--- a/crates/fonts/src/fontdb/face.rs
+++ b/crates/fonts/src/fontdb/face.rs
@@ -64,12 +64,12 @@ pub struct FontFace {
     /// values).
     pub cap_height: f64,
     pub x_height: f64,
-    pub shaper: harfrust::ShaperData,
-    /// Variable fonts: the named instance's axis settings (user units; empty = default instance),
-    /// the normalised location and the shaper's view of it.
+    /// The shaper's view of the face, at the named instance's location for variable fonts.
+    pub hb: harfrust::Font,
+    /// Variable fonts: the named instance's axis settings (user units; empty = default instance)
+    /// and the normalised location.
     pub coords: Vec<([u8; 4], f32)>,
     pub(super) location: Location,
-    pub instance: Option<harfrust::ShaperInstance>,
     /// Basic Multilingual Plane coverage bitset, built on first use.
     pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,
     // Some legacy CJK fonts assemble their contours with TrueType instructions.
@@ -185,9 +185,6 @@ impl FontFace {
     pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
         skrifa::FontRef::from_index(self.data(), self.index).ok()
     }
-    pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
-        harfrust::FontRef::from_index(self.data(), self.index).ok()
-    }
     /// Where in the design space glyphs, metrics and outlines come from.
     pub fn location(&self) -> LocationRef<'_> {
         (&self.location).into()
diff --git a/crates/fonts/src/fontdb/names.rs b/crates/fonts/src/fontdb/names.rs
--- a/crates/fonts/src/fontdb/names.rs
+++ b/crates/fonts/src/fontdb/names.rs
@@ -352,10 +352,12 @@ pub(super) fn make_face(
     let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) };
     let m = f.metrics(Size::unscaled(), &location);
     let a = f.attributes();
-    let hb = harfrust::FontRef::from_index(data, index).ok()?;
-    let shaper = harfrust::ShaperData::new(&hb);
-    let instance = (!coords.is_empty())
-        .then(|| harfrust::ShaperInstance::from_variations(&hb, settings.iter().map(|(t, v)| harfrust::Variation { tag: *t, value: *v })));
+    let source: harfrust::font::Source = match &bytes {
+        FontBytes::Static(b) => (*b).into(),
+        FontBytes::Owned(v) => (v.clone() as Arc<dyn AsRef<[u8]> + Send + Sync>).into(),
+    };
+    let hb = harfrust::Font::new(source, index)?;
+    let hb = if coords.is_empty() { hb } else { hb.instance_builder().variations(coords.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))).build() };
     let axis = |tag: &[u8; 4]| coords.iter().find(|(t, _)| t == tag).map(|(_, v)| *v);
     // Old releases of heavy faces (Arial Black in the core fonts) claim weight 400 where current
     // ones say 900, which Word doesn't embolden: a family named for its weight is that heavy.
@@ -379,10 +381,9 @@ pub(super) fn make_face(
         descent: -(m.descent as f64),
         cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
         x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
-        shaper,
+        hb,
         coords,
         location,
-        instance,
         bytes,
         index,
         bmp: std::sync::OnceLock::new(),
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -19,7 +19,7 @@ pub use fontdb::{
     base_style, bundled, decode_font_file, is_font_file_name, name_language_matches, sniff, system_font_dirs,
 };
 pub use harfrust::Feature;
-use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer};
+use harfrust::{Buffer, Direction, ShapeOptions, ShapePlan, ShapePlanKey, ShaperFont, Tag};
 pub use kurbo::BezPath;
 pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
 use skrifa::MetadataProvider;
@@ -208,35 +208,35 @@ pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn
 
 fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
     let mut out = Vec::with_capacity(text.len());
-    let shaped = face.hb().map(|hb| {
-        let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
-        // One script per shaping call: the shaper picks its script engine (Myanmar, Khmer,
-        // Indic, USE reordering…) from the run's script, so "Hi မြန်မာ" in one face must not be
-        // shaped as Latin. Runs come in text order; right-to-left callers regroup by cluster.
-        for r in script::script_runs(text) {
-            let Some(sub) = text.get(r.clone()) else { continue };
-            let mut buf = UnicodeBuffer::new();
-            for (i, c) in sub.char_indices() {
-                buf.add(map(c), (r.start + i) as u32);
-            }
-            buf.guess_segment_properties();
-            buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
-            let plan = shape_plan(face, &shaper, &buf, features);
-            let gb = shaper.shape(buf, ShapeOptions::new().plan(plan.as_deref()).features(features));
-            for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
-                out.push(ShapedGlyph {
-                    gid: info.glyph_id,
-                    cluster: info.cluster as usize,
-                    x_advance: pos.x_advance,
-                    x_offset: pos.x_offset,
-                    y_offset: pos.y_offset,
-                });
-            }
+    let sf = ShaperFont::new(&face.hb);
+    // One script per shaping call: the shaper picks its script engine (Myanmar, Khmer,
+    // Indic, USE reordering…) from the run's script, so "Hi မြန်မာ" in one face must not be
+    // shaped as Latin. Runs come in text order; right-to-left callers regroup by cluster.
+    let mut shaped = false;
+    for r in script::script_runs(text) {
+        let Some(sub) = text.get(r.clone()) else { continue };
+        let mut buf = Buffer::new();
+        for (i, c) in sub.char_indices() {
+            buf.push(map(c) as u32, (r.start + i) as u32);
         }
-    });
-    if shaped.is_none()
-        && let Some(f) = face.skrifa()
-    {
+        buf.guess_segment_properties();
+        buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
+        let plan = shape_plan(face, &buf, features);
+        if harfrust::shape(&sf, &mut buf, ShapeOptions::new().plan(plan.as_deref()).features(features)).is_err() {
+            continue;
+        }
+        shaped = true;
+        for (info, pos) in buf.glyph_infos().iter().zip(buf.glyph_positions()) {
+            out.push(ShapedGlyph {
+                gid: info.glyph_id,
+                cluster: info.cluster as usize,
+                x_advance: pos.x_advance,
+                x_offset: pos.x_offset,
+                y_offset: pos.y_offset,
+            });
+        }
+    }
+    if !shaped && let Some(f) = face.skrifa() {
         let cmap = f.charmap();
         let gm = f.glyph_metrics(Size::unscaled(), face.location());
         for (i, c) in text.char_indices() {
@@ -281,22 +281,22 @@ thread_local! {
 /// oldest dropped past [`MAX_SHAPE_PLANS`]). A kept plan is used only for the same face and when
 /// harfrust's own [`ShapePlanKey::matches`] accepts it (script, direction, language, the face's
 /// variation instance and the features). `None` leaves making it to the shaper.
-fn shape_plan(face: &FontFace, shaper: &Shaper, buf: &UnicodeBuffer, features: &[Feature]) -> Option<std::rc::Rc<ShapePlan>> {
+fn shape_plan(face: &FontFace, buf: &Buffer, features: &[Feature]) -> Option<std::rc::Rc<ShapePlan>> {
     // The buffer's script is unset when it reads as unknown: the guess never sets that.
-    let script = Some(buf.script()).filter(|s| *s != harfrust::script::UNKNOWN);
+    let script = buf.script().filter(|s| *s != harfrust::Script::UNKNOWN);
     let language = buf.language();
     let direction = buf.direction();
     if direction == Direction::Invalid {
         return None;
     }
-    let key = ShapePlanKey::new(script, direction).language(language.as_ref()).instance(face.instance.as_ref()).features(features);
+    let key = ShapePlanKey::new(&face.hb, script, direction).language(language).features(features);
     let of = PlanFace::of(face);
     SHAPE_PLANS.with(|cell| {
         let mut plans = cell.try_borrow_mut().ok()?;
         if let Some((_, plan)) = plans.iter().find(|(f, plan)| *f == of && key.matches(plan)) {
             return Some(plan.clone());
         }
-        let plan = std::rc::Rc::new(ShapePlan::new(shaper, direction, script, language.as_ref(), features));
+        let plan = std::rc::Rc::new(ShapePlan::new(&face.hb, direction, script, language, features));
         if plans.len() >= MAX_SHAPE_PLANS {
             plans.remove(0);
         }
@@ -361,14 +361,14 @@ mod tests {
     #[test]
     fn kept_shape_plans_shape_as_fresh_ones() {
         let fresh = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
-            let Some(hb) = face.hb() else { return Vec::new() };
-            let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
-            let mut buf = UnicodeBuffer::new();
+            let mut buf = Buffer::new();
             buf.push_str(text);
             buf.guess_segment_properties();
             buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
-            let gb = shaper.shape(buf, ShapeOptions::new().features(features));
-            gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
+            if harfrust::shape(&ShaperFont::new(&face.hb), &mut buf, ShapeOptions::new().features(features)).is_err() {
+                return Vec::new();
+            }
+            buf.glyph_infos().iter().zip(buf.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
         };
         let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
             shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect()
@@ -394,14 +394,14 @@ mod tests {
     fn kept_shape_plans_are_bounded() {
         let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
         let fresh = |features: &[Feature], rtl: bool| -> Vec<(u32, i32)> {
-            let Some(hb) = face.hb() else { return Vec::new() };
-            let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
-            let mut buf = UnicodeBuffer::new();
+            let mut buf = Buffer::new();
             buf.push_str("office");
             buf.guess_segment_properties();
             buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
-            let gb = shaper.shape(buf, ShapeOptions::new().features(features));
-            gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, p.x_advance)).collect()
+            if harfrust::shape(&ShaperFont::new(&face.hb), &mut buf, ShapeOptions::new().features(features)).is_err() {
+                return Vec::new();
+            }
+            buf.glyph_infos().iter().zip(buf.glyph_positions()).map(|(i, p)| (i.glyph_id, p.x_advance)).collect()
         };
         for n in 0..3 * MAX_SHAPE_PLANS as u32 {
             let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)];
