Replace rustybuzz with HarfRust (#417)

* Use HarfRust for shaping

* Replace ttf-parser with skrifa entirely

* Fix clippy lints

* Add shape plan cache

* Bump harfrust and skrifa

* Fix no_std build

* Simplify the shape plan cache

* Please the paperclip

* Cache font ID with plan

* Tune shape plan cache for "BiDi Processing" bench
This commit is contained in:
valadaptive 2025-09-08 23:15:27 -04:00 committed by GitHub
parent 3c1f6c9e8a
commit 2610c869f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 221 additions and 121 deletions

View file

@ -7,25 +7,25 @@ edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
documentation = "https://docs.rs/cosmic-text/latest/cosmic_text/" documentation = "https://docs.rs/cosmic-text/latest/cosmic_text/"
repository = "https://github.com/pop-os/cosmic-text" repository = "https://github.com/pop-os/cosmic-text"
rust-version = "1.75" rust-version = "1.80"
[dependencies] [dependencies]
bitflags = "2.4.1" bitflags = "2.4.1"
core_maths = { version = "0.1.1", optional = true } core_maths = { version = "0.1.1", optional = true }
cosmic_undo_2 = { version = "0.2.0", optional = true } cosmic_undo_2 = { version = "0.2.0", optional = true }
fontdb = { version = "0.23", default-features = false } fontdb = { version = "0.23", default-features = false }
harfrust = { version = "0.2.0", default-features = false }
hashbrown = { version = "0.14.1", optional = true, default-features = false } hashbrown = { version = "0.14.1", optional = true, default-features = false }
libm = { version = "0.2.8", optional = true } libm = { version = "0.2.8", optional = true }
log = "0.4.20" log = "0.4.20"
modit = { version = "0.1.4", optional = true } modit = { version = "0.1.4", optional = true }
rangemap = "1.4.0" rangemap = "1.4.0"
rustc-hash = { version = "1.1.0", default-features = false } rustc-hash = { version = "1.1.0", default-features = false }
rustybuzz = { version = "0.14", default-features = false, features = ["libm"] }
self_cell = "1.0.1" self_cell = "1.0.1"
skrifa = { version = "0.36.0", default-features = false }
smol_str = { version = "0.2.2", default-features = false } smol_str = { version = "0.2.2", default-features = false }
syntect = { version = "5.1.0", optional = true } syntect = { version = "5.1.0", optional = true }
sys-locale = { version = "0.3.1", optional = true } sys-locale = { version = "0.3.1", optional = true }
ttf-parser = { version = "0.21", default-features = false }
unicode-linebreak = "0.1.5" unicode-linebreak = "0.1.5"
unicode-script = "0.5.5" unicode-script = "0.5.5"
unicode-segmentation = "1.10.1" unicode-segmentation = "1.10.1"
@ -49,16 +49,16 @@ optional = true
default = ["std", "swash", "fontconfig"] default = ["std", "swash", "fontconfig"]
fontconfig = ["fontdb/fontconfig", "std"] fontconfig = ["fontdb/fontconfig", "std"]
monospace_fallback = [] monospace_fallback = []
no_std = ["rustybuzz/libm", "hashbrown", "dep:libm", "core_maths"] no_std = ["hashbrown", "dep:libm", "skrifa/libm", "core_maths"]
peniko = ["dep:peniko"] peniko = ["dep:peniko"]
shape-run-cache = [] shape-run-cache = []
std = [ std = [
"fontdb/memmap", "fontdb/memmap",
"fontdb/std", "fontdb/std",
"rustybuzz/std", "harfrust/std",
"skrifa/std",
"swash?/std", "swash?/std",
"sys-locale", "sys-locale",
"ttf-parser/std",
"unicode-bidi/std", "unicode-bidi/std",
] ]
vi = ["modit", "syntect", "cosmic_undo_2"] vi = ["modit", "syntect", "cosmic_undo_2"]

View file

@ -8,7 +8,7 @@
Pure Rust multi-line text handling. Pure Rust multi-line text handling.
COSMIC Text provides advanced text shaping, layout, and rendering wrapped up COSMIC Text provides advanced text shaping, layout, and rendering wrapped up
into a simple abstraction. Shaping is provided by rustybuzz, and supports a into a simple abstraction. Shaping is provided by HarfRust, and supports a
wide variety of advanced shaping operations. Rendering is provided by swash, wide variety of advanced shaping operations. Rendering is provided by swash,
which supports ligatures and color emoji. Layout is implemented custom, in safe which supports ligatures and color emoji. Layout is implemented custom, in safe
Rust, and supports bidirectional text. Font fallback is also a custom Rust, and supports bidirectional text. Font fallback is also a custom
@ -37,7 +37,7 @@ The following features must be supported before this is "ready":
- [x] Text styles (bold, italic, etc.) - [x] Text styles (bold, italic, etc.)
- [x] Per-buffer - [x] Per-buffer
- [x] Per-span - [x] Per-span
- [x] Font shaping (using rustybuzz) - [x] Font shaping (using HarfRust)
- [x] Cache results - [x] Cache results
- [x] RTL - [x] RTL
- [x] Bidirectional rendering - [x] Bidirectional rendering

View file

@ -44,6 +44,28 @@ fn bench_bidi_processing(c: &mut Criterion) {
}); });
} }
fn bench_lang_mixed(c: &mut Criterion) {
let mut fs = ct::FontSystem::new();
let mut buffer = ct::Buffer::new(&mut fs, ct::Metrics::new(14.0, 20.0));
buffer.set_size(&mut fs, Some(500.0), None);
let bidi_text = include_str!("../sample/hello.txt");
c.benchmark_group("bench_lang_mixed")
.sample_size(10)
.bench_function("ShapeLine/Mixed-Language Text", |b| {
b.iter(|| {
buffer.set_text(
&mut fs,
black_box(&bidi_text),
&ct::Attrs::new(),
ct::Shaping::Advanced,
);
buffer.shape_until_scroll(&mut fs, false);
});
});
}
fn bench_layout_heavy(c: &mut Criterion) { fn bench_layout_heavy(c: &mut Criterion) {
let mut fs = ct::FontSystem::new(); let mut fs = ct::FontSystem::new();
let mut buffer = ct::Buffer::new(&mut fs, ct::Metrics::new(14.0, 20.0)); let mut buffer = ct::Buffer::new(&mut fs, ct::Metrics::new(14.0, 20.0));
@ -117,6 +139,7 @@ criterion_group!(
benches, benches,
bench_ascii_fast_path, bench_ascii_fast_path,
bench_bidi_processing, bench_bidi_processing,
bench_lang_mixed,
bench_layout_heavy, bench_layout_heavy,
bench_combined_stress, bench_combined_stress,
bench_bidi_paragraphs_ascii, bench_bidi_paragraphs_ascii,

View file

@ -1,7 +1,10 @@
// SPDX-License-Identifier: MIT OR Apache-2.0 // SPDX-License-Identifier: MIT OR Apache-2.0
// re-export ttf_parser use harfrust::Shaper;
pub use ttf_parser; use skrifa::raw::{ReadError, TableProvider as _};
use skrifa::{metrics::Metrics, prelude::*};
// re-export skrifa
pub use skrifa;
// re-export peniko::Font; // re-export peniko::Font;
#[cfg(feature = "peniko")] #[cfg(feature = "peniko")]
pub use peniko::Font as PenikoFont; pub use peniko::Font as PenikoFont;
@ -12,7 +15,6 @@ use alloc::sync::Arc;
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::vec::Vec; use alloc::vec::Vec;
use rustybuzz::Face as RustybuzzFace;
use self_cell::self_cell; use self_cell::self_cell;
pub mod fallback; pub mod fallback;
@ -21,12 +23,19 @@ pub use fallback::{Fallback, PlatformFallback};
pub use self::system::*; pub use self::system::*;
mod system; mod system;
struct OwnedFaceData {
data: Arc<dyn AsRef<[u8]> + Send + Sync>,
shaper_data: harfrust::ShaperData,
shaper_instance: harfrust::ShaperInstance,
metrics: Metrics,
}
self_cell!( self_cell!(
struct OwnedFace { struct OwnedFace {
owner: Arc<dyn AsRef<[u8]> + Send + Sync>, owner: OwnedFaceData,
#[covariant] #[covariant]
dependent: RustybuzzFace, dependent: Shaper,
} }
); );
@ -40,7 +49,7 @@ struct FontMonospaceFallback {
pub struct Font { pub struct Font {
#[cfg(feature = "swash")] #[cfg(feature = "swash")]
swash: (u32, swash::CacheKey), swash: (u32, swash::CacheKey),
rustybuzz: OwnedFace, harfrust: OwnedFace,
#[cfg(not(feature = "peniko"))] #[cfg(not(feature = "peniko"))]
data: Arc<dyn AsRef<[u8]> + Send + Sync>, data: Arc<dyn AsRef<[u8]> + Send + Sync>,
#[cfg(feature = "peniko")] #[cfg(feature = "peniko")]
@ -89,8 +98,16 @@ impl Font {
} }
} }
pub fn rustybuzz(&self) -> &RustybuzzFace<'_> { pub fn shaper(&self) -> &harfrust::Shaper<'_> {
self.rustybuzz.borrow_dependent() self.harfrust.borrow_dependent()
}
pub(crate) fn shaper_instance(&self) -> &harfrust::ShaperInstance {
&self.harfrust.borrow_owner().shaper_instance
}
pub fn metrics(&self) -> &Metrics {
&self.harfrust.borrow_owner().metrics
} }
#[cfg(feature = "peniko")] #[cfg(feature = "peniko")]
@ -113,59 +130,6 @@ impl Font {
pub fn new(db: &fontdb::Database, id: fontdb::ID, weight: fontdb::Weight) -> Option<Self> { pub fn new(db: &fontdb::Database, id: fontdb::ID, weight: fontdb::Weight) -> Option<Self> {
let info = db.face(id)?; let info = db.face(id)?;
let monospace_fallback = if cfg!(feature = "monospace_fallback") {
db.with_face_data(id, |font_data, face_index| {
let face = ttf_parser::Face::parse(font_data, face_index).ok()?;
let monospace_em_width = info
.monospaced
.then(|| {
let hor_advance = face.glyph_hor_advance(face.glyph_index(' ')?)?;
let upem = face.units_per_em();
Some(f32::from(hor_advance) / f32::from(upem))
})
.flatten();
if info.monospaced && monospace_em_width.is_none() {
None?;
}
let scripts = face
.tables()
.gpos
.into_iter()
.chain(face.tables().gsub)
.flat_map(|table| table.scripts)
.map(|script| script.tag.to_bytes())
.collect();
let mut unicode_codepoints = Vec::new();
face.tables()
.cmap?
.subtables
.into_iter()
.filter(ttf_parser::cmap::Subtable::is_unicode)
.for_each(|subtable| {
unicode_codepoints.reserve(1024);
subtable.codepoints(|code_point| {
if subtable.glyph_index(code_point).is_some() {
unicode_codepoints.push(code_point);
}
});
});
unicode_codepoints.shrink_to_fit();
Some(FontMonospaceFallback {
monospace_em_width,
scripts,
unicode_codepoints,
})
})?
} else {
None
};
let data = match &info.source { let data = match &info.source {
fontdb::Source::Binary(data) => Arc::clone(data), fontdb::Source::Binary(data) => Arc::clone(data),
#[cfg(feature = "std")] #[cfg(feature = "std")]
@ -177,6 +141,77 @@ impl Font {
fontdb::Source::SharedFile(_path, data) => Arc::clone(data), fontdb::Source::SharedFile(_path, data) => Arc::clone(data),
}; };
// It's a bit unfortunate but we need to parse the data into a `FontRef`
// twice--once to construct the HarfRust `ShaperInstance` and
// `ShaperData`, and once to create the persistent `FontRef` tied to the
// lifetime of the face data.
let font_ref = FontRef::from_index((*data).as_ref(), info.index).ok()?;
let location = font_ref
.axes()
.location([(Tag::new(b"wght"), weight.0 as f32)]);
let metrics = font_ref.metrics(Size::unscaled(), &location);
let monospace_fallback = if cfg!(feature = "monospace_fallback") {
(|| {
let glyph_metrics = font_ref.glyph_metrics(Size::unscaled(), &location);
let charmap = font_ref.charmap();
let monospace_em_width = info
.monospaced
.then(|| {
let hor_advance = glyph_metrics.advance_width(charmap.map(' ')?)?;
let upem = metrics.units_per_em;
Some(hor_advance / f32::from(upem))
})
.flatten();
if info.monospaced && monospace_em_width.is_none() {
None?;
}
let scripts = font_ref
.gpos()
.ok()?
.script_list()
.ok()?
.script_records()
.iter()
.chain(
font_ref
.gsub()
.ok()?
.script_list()
.ok()?
.script_records()
.iter(),
)
.map(|script| script.script_tag().into_bytes())
.collect();
let mut unicode_codepoints = Vec::new();
for (code_point, _) in charmap.mappings() {
unicode_codepoints.push(code_point);
}
unicode_codepoints.shrink_to_fit();
Some(FontMonospaceFallback {
monospace_em_width,
scripts,
unicode_codepoints,
})
})()
} else {
None
};
let (shaper_instance, shaper_data) = {
(
harfrust::ShaperInstance::from_coords(&font_ref, location.coords().iter().copied()),
harfrust::ShaperData::new(&font_ref),
)
};
Some(Self { Some(Self {
id: info.id, id: info.id,
monospace_fallback, monospace_fallback,
@ -185,21 +220,27 @@ impl Font {
let swash = swash::FontRef::from_index((*data).as_ref(), info.index as usize)?; let swash = swash::FontRef::from_index((*data).as_ref(), info.index as usize)?;
(swash.offset, swash.key) (swash.offset, swash.key)
}, },
rustybuzz: OwnedFace::try_new(Arc::clone(&data), |data| { harfrust: OwnedFace::try_new(
RustybuzzFace::from_slice((**data).as_ref(), info.index) OwnedFaceData {
.ok_or(()) data: Arc::clone(&data),
.map(|mut face| { shaper_data,
if let Some(axis) = face shaper_instance,
.variation_axes() metrics,
.into_iter() },
.find(|axis| axis.tag == ttf_parser::Tag::from_bytes(b"wght")) |OwnedFaceData {
{ data,
let wght = f32::from(weight.0).clamp(axis.min_value, axis.max_value); shaper_data,
let _ = face.set_variation(ttf_parser::Tag::from_bytes(b"wght"), wght); shaper_instance,
} ..
face }| {
}) let font_ref = FontRef::from_index((**data).as_ref(), info.index)?;
}) let shaper = shaper_data
.shaper(&font_ref)
.instance(Some(shaper_instance))
.build();
Ok::<_, ReadError>(shaper)
},
)
.ok()?, .ok()?,
#[cfg(not(feature = "peniko"))] #[cfg(not(feature = "peniko"))]
data, data,

View file

@ -7,10 +7,11 @@ use alloc::vec::Vec;
use core::fmt; use core::fmt;
use core::ops::{Deref, DerefMut}; use core::ops::{Deref, DerefMut};
use fontdb::Query; use fontdb::Query;
use skrifa::raw::{ReadError, TableProvider as _};
// re-export fontdb and rustybuzz // re-export fontdb and harfrust
pub use fontdb; pub use fontdb;
pub use rustybuzz; pub use harfrust;
use super::fallback::{Fallback, Fallbacks, MonospaceFallbackInfo, PlatformFallback}; use super::fallback::{Fallback, Fallbacks, MonospaceFallbackInfo, PlatformFallback};
@ -182,19 +183,20 @@ impl FontSystem {
if cfg!(feature = "monospace_fallback") { if cfg!(feature = "monospace_fallback") {
for &id in &monospace_font_ids { for &id in &monospace_font_ids {
db.with_face_data(id, |font_data, face_index| { db.with_face_data(id, |font_data, face_index| {
let _ = ttf_parser::Face::parse(font_data, face_index).map(|face| { let face = skrifa::FontRef::from_index(font_data, face_index)?;
face.tables() for script in face
.gpos .gpos()?
.into_iter() .script_list()?
.chain(face.tables().gsub) .script_records()
.flat_map(|table| table.scripts) .iter()
.inspect(|script| { .chain(face.gsub()?.script_list()?.script_records().iter())
per_script_monospace_font_ids {
.entry(script.tag.to_bytes()) per_script_monospace_font_ids
.or_default() .entry(script.script_tag().into_bytes())
.insert(id); .or_default()
}) .insert(id);
}); }
Ok::<_, ReadError>(())
}); });
} }
} }

View file

@ -4,7 +4,7 @@
//! //!
//! This library provides advanced text handling in a generic way. It provides abstractions for //! This library provides advanced text handling in a generic way. It provides abstractions for
//! shaping, font discovery, font fallback, layout, rasterization, and editing. Shaping utilizes //! shaping, font discovery, font fallback, layout, rasterization, and editing. Shaping utilizes
//! rustybuzz, font discovery utilizes fontdb, and the rasterization is optional and utilizes //! harfrust, font discovery utilizes fontdb, and the rasterization is optional and utilizes
//! swash. The other features are developed internal to this library. //! swash. The other features are developed internal to this library.
//! //!
//! It is recommended that you start by creating a [`FontSystem`], after which you can create a //! It is recommended that you start by creating a [`FontSystem`], after which you can create a

View file

@ -10,6 +10,7 @@ use crate::{
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::vec::Vec; use alloc::vec::Vec;
use alloc::collections::VecDeque;
use core::cmp::{max, min}; use core::cmp::{max, min};
use core::fmt; use core::fmt;
use core::mem; use core::mem;
@ -78,11 +79,17 @@ impl Shaping {
} }
} }
const NUM_SHAPE_PLANS: usize = 6;
/// A set of buffers containing allocations for shaped text. /// A set of buffers containing allocations for shaped text.
#[derive(Default)] #[derive(Default)]
pub struct ShapeBuffer { pub struct ShapeBuffer {
/// Cache for harfrust shape plans. Stores up to [`NUM_SHAPE_PLANS`] plans at once. Inserting a new one past that
/// will remove the one that was least recently added (not least recently used).
shape_plan_cache: VecDeque<(fontdb::ID, harfrust::ShapePlan)>,
/// Buffer for holding unicode text. /// Buffer for holding unicode text.
rustybuzz_buffer: Option<rustybuzz::UnicodeBuffer>, harfrust_buffer: Option<harfrust::UnicodeBuffer>,
/// Temporary buffers for scripts. /// Temporary buffers for scripts.
scripts: Vec<Script>, scripts: Vec<Script>,
@ -119,15 +126,15 @@ fn shape_fallback(
) -> Vec<usize> { ) -> Vec<usize> {
let run = &line[start_run..end_run]; let run = &line[start_run..end_run];
let font_scale = font.rustybuzz().units_per_em() as f32; let font_scale = font.metrics().units_per_em as f32;
let ascent = f32::from(font.rustybuzz().ascender()) / font_scale; let ascent = font.metrics().ascent / font_scale;
let descent = -f32::from(font.rustybuzz().descender()) / font_scale; let descent = -font.metrics().descent / font_scale;
let mut buffer = scratch.rustybuzz_buffer.take().unwrap_or_default(); let mut buffer = scratch.harfrust_buffer.take().unwrap_or_default();
buffer.set_direction(if span_rtl { buffer.set_direction(if span_rtl {
rustybuzz::Direction::RightToLeft harfrust::Direction::RightToLeft
} else { } else {
rustybuzz::Direction::LeftToRight harfrust::Direction::LeftToRight
}); });
if run.contains('\t') { if run.contains('\t') {
// Push string to buffer, replacing tabs with spaces // Push string to buffer, replacing tabs with spaces
@ -140,29 +147,56 @@ fn shape_fallback(
} }
buffer.guess_segment_properties(); buffer.guess_segment_properties();
let rtl = matches!(buffer.direction(), rustybuzz::Direction::RightToLeft); let rtl = matches!(buffer.direction(), harfrust::Direction::RightToLeft);
assert_eq!(rtl, span_rtl); assert_eq!(rtl, span_rtl);
let attrs = attrs_list.get_span(start_run); let attrs = attrs_list.get_span(start_run);
let mut rb_font_features = Vec::new(); let mut rb_font_features = Vec::new();
// Convert attrs::Feature to rustybuzz::Feature // Convert attrs::Feature to harfrust::Feature
for feature in attrs.font_features.features { for feature in &attrs.font_features.features {
rb_font_features.push(rustybuzz::Feature::new( rb_font_features.push(harfrust::Feature::new(
rustybuzz::ttf_parser::Tag::from_bytes(feature.tag.as_bytes()), harfrust::Tag::new(feature.tag.as_bytes()),
feature.value, feature.value,
0..usize::MAX, 0..usize::MAX,
)); ));
} }
let shape_plan = rustybuzz::ShapePlan::new( let language = buffer.language();
font.rustybuzz(), let key = harfrust::ShapePlanKey::new(Some(buffer.script()), buffer.direction())
buffer.direction(), .features(&rb_font_features)
Some(buffer.script()), .instance(Some(font.shaper_instance()))
buffer.language().as_ref(), .language(language.as_ref());
&rb_font_features,
); let shape_plan = match scratch
let glyph_buffer = rustybuzz::shape_with_plan(font.rustybuzz(), &shape_plan, buffer); .shape_plan_cache
.iter()
.find(|(id, plan)| *id == font.id() && key.matches(plan))
{
Some((_font_id, plan)) => plan,
None => {
let plan = harfrust::ShapePlan::new(
font.shaper(),
buffer.direction(),
Some(buffer.script()),
buffer.language().as_ref(),
&rb_font_features,
);
if scratch.shape_plan_cache.len() >= NUM_SHAPE_PLANS {
scratch.shape_plan_cache.pop_front();
}
scratch.shape_plan_cache.push_back((font.id(), plan));
&scratch
.shape_plan_cache
.back()
.expect("we just pushed the shape plan")
.1
}
};
let glyph_buffer = font
.shaper()
.shape_with_plan(shape_plan, buffer, &rb_font_features);
let glyph_infos = glyph_buffer.glyph_infos(); let glyph_infos = glyph_buffer.glyph_infos();
let glyph_positions = glyph_buffer.glyph_positions(); let glyph_positions = glyph_buffer.glyph_positions();
@ -230,7 +264,7 @@ fn shape_fallback(
} }
// Restore the buffer to save an allocation. // Restore the buffer to save an allocation.
scratch.rustybuzz_buffer = Some(glyph_buffer.clear()); scratch.harfrust_buffer = Some(glyph_buffer.clear());
missing missing
} }