From 62ce510b509f12a386dbfc9b01c9d711280f6484 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 08:39:52 -0800 Subject: [PATCH 01/15] Take the last value of a feature the list repeats HarfBuzz's map builder merges the entries of a global feature, and a later entry replaces the value of an earlier one, so liga=0,liga=1 turns ligatures on and kern=0,kern=1 kerns. sigilbuzz treated any zero entry as off. shape() now keeps each repeated tag at its last entry before any pass reads the list, and the disabled and enabled checks read the last entry too. --- src/ot/syllabic/stage.rs | 5 +-- src/shape.rs | 62 ++++++++++++++++++++++++++++++++---- src/shape/pipeline.rs | 8 ++++- src/shape/tests.rs | 33 +++++++++++++++++++ tests/repeated_features.rs | 65 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 tests/repeated_features.rs diff --git a/src/ot/syllabic/stage.rs b/src/ot/syllabic/stage.rs index 21b36cba..4f43cb17 100644 --- a/src/ot/syllabic/stage.rs +++ b/src/ot/syllabic/stage.rs @@ -117,9 +117,10 @@ impl StageFeature { } } -/// True when the caller turned `tag` off with a zero-valued feature. +/// True when the caller turned `tag` off: the last entry for it in the +/// feature list has value 0. pub(crate) fn user_disabled(features: &[Feature], tag: [u8; 4]) -> bool { - features.iter().any(|f| f.tag == tag && f.value == 0) + crate::shape::last_value(features, tag) == Some(0) } /// The lookups of `tag` in the font, for a run of candidate script diff --git a/src/shape.rs b/src/shape.rs index de0cf32a..366debf1 100644 --- a/src/shape.rs +++ b/src/shape.rs @@ -56,7 +56,8 @@ //! - AAT `morx` substitution for fonts without GSUB. //! //! Any default-on feature can be suppressed by a `Feature { tag, -//! value: 0 }` entry. +//! value: 0 }` entry. A list that names a tag more than once takes its +//! last entry, as HarfBuzz does. //! //! # Direction and output order //! @@ -334,16 +335,63 @@ pub struct Feature { pub value: u32, } -/// Returns `true` when the feature is explicitly disabled via -/// `Feature { tag, value: 0 }` in the override list. +/// Returns `true` when the override list turns the feature off: its +/// last entry for `tag` has value 0. fn feature_disabled(features: &[Feature], tag: [u8; 4]) -> bool { - features.iter().any(|f| f.tag == tag && f.value == 0) + last_value(features, tag) == Some(0) } -/// Returns `true` when the override list turns the feature on and does -/// not also turn it off. +/// Returns `true` when the override list turns the feature on: its +/// last entry for `tag` has a non-zero value. fn feature_enabled(features: &[Feature], tag: [u8; 4]) -> bool { - features.iter().any(|f| f.tag == tag && f.value != 0) && !feature_disabled(features, tag) + last_value(features, tag).is_some_and(|v| v != 0) +} + +/// The value of the last entry for `tag` in `features`, the one that +/// counts when a list repeats a tag. +pub(crate) fn last_value(features: &[Feature], tag: [u8; 4]) -> Option { + features + .iter() + .rev() + .find(|f| f.tag == tag) + .map(|f| f.value) +} + +/// `features` with every tag the list repeats kept only at its last +/// entry, the other entries in their order. HarfBuzz's map builder +/// merges the entries of a tag the same way: a later global feature +/// replaces the value of an earlier one (`hb_ot_map_builder_t::compile`), +/// so `liga=0,liga=1` turns `liga` on. Borrows `features` when no tag +/// repeats. +fn last_values(features: &[Feature]) -> alloc::borrow::Cow<'_, [Feature]> { + use alloc::borrow::Cow; + use alloc::collections::BTreeMap; + // Small lists, the usual case, need no map to find a repeat. + let repeats = if features.len() <= 16 { + features + .iter() + .enumerate() + .any(|(i, f)| features[i + 1..].iter().any(|g| g.tag == f.tag)) + } else { + let mut seen = alloc::collections::BTreeSet::new(); + features.iter().any(|f| !seen.insert(f.tag)) + }; + if !repeats { + return Cow::Borrowed(features); + } + let last: BTreeMap<[u8; 4], usize> = features + .iter() + .enumerate() + .map(|(i, f)| (f.tag, i)) + .collect(); + Cow::Owned( + features + .iter() + .enumerate() + .filter(|&(i, f)| last.get(&f.tag) == Some(&i)) + .map(|(_, f)| *f) + .collect(), + ) } /// Resolves a GPOS/GSUB type-9 Extension subtable to its inner diff --git a/src/shape/pipeline.rs b/src/shape/pipeline.rs index dc27a3e1..2f2a2132 100644 --- a/src/shape/pipeline.rs +++ b/src/shape/pipeline.rs @@ -30,7 +30,9 @@ use crate::unicode::{script_of, Script}; /// Feature tags with `value: 0` disable the corresponding feature /// for this call. Non-zero values enable a feature if the font /// supports it. Unknown tags are accepted and ignored rather than -/// returning an error. +/// returning an error. When the list names a tag more than once, the +/// last entry wins, as in HarfBuzz: `liga=0` then `liga=1` turns +/// ligatures on. /// /// # Output order /// @@ -48,6 +50,10 @@ use crate::unicode::{script_of, Script}; // The pipeline is a straight-line sequence of passes so the order is // visible in one place. pub fn shape(font: &Font<'_>, buffer: &Buffer, features: &[Feature]) -> Result { + // A tag the list repeats takes its last value, as in HarfBuzz, so + // every pass below sees each tag once. + let features = super::last_values(features); + let features: &[Feature] = &features; // Vertical layout: explicit when the buffer direction is TTB/BTT, // *implicit* when the run is dominantly Mongolian and the caller // never chose a direction. Mongolian's traditional writing axis is diff --git a/src/shape/tests.rs b/src/shape/tests.rs index e7c79b3c..12fa33c1 100644 --- a/src/shape/tests.rs +++ b/src/shape/tests.rs @@ -265,3 +265,36 @@ fn resolve_extension_rejects_offset_past_end() { bytes.extend_from_slice(&9999u32.to_be_bytes()); assert!(resolve_extension(&bytes).is_none()); } + +#[test] +fn a_repeated_tag_keeps_its_last_entry_in_place() { + let f = |tag: &[u8; 4], value: u32| Feature { tag: *tag, value }; + let list = [f(b"liga", 0), f(b"kern", 1), f(b"liga", 1), f(b"salt", 2)]; + assert_eq!( + &*last_values(&list), + &[f(b"kern", 1), f(b"liga", 1), f(b"salt", 2)] + ); + assert!(feature_enabled(&list, *b"liga")); + assert!(!feature_disabled(&list, *b"liga")); + let list = [f(b"kern", 1), f(b"kern", 0)]; + assert_eq!(&*last_values(&list), &[f(b"kern", 0)]); + assert!(feature_disabled(&list, *b"kern")); + assert!(!feature_enabled(&list, *b"kern")); + // A list without repeats is borrowed as it is, long or short. + let short = [f(b"liga", 0), f(b"kern", 0)]; + assert!(matches!( + last_values(&short), + alloc::borrow::Cow::Borrowed(_) + )); + let long: Vec = (0..40u8).map(|i| f(&[b'a', b'b', b'c', i], 1)).collect(); + assert!(matches!( + last_values(&long), + alloc::borrow::Cow::Borrowed(_) + )); + let mut repeated = long.clone(); + repeated.push(f(&[b'a', b'b', b'c', 3], 0)); + let kept = last_values(&repeated); + assert_eq!(kept.len(), 40); + assert_eq!(kept.last(), Some(&f(&[b'a', b'b', b'c', 3], 0))); + assert!(!kept.contains(&f(&[b'a', b'b', b'c', 3], 1))); +} diff --git a/tests/repeated_features.rs b/tests/repeated_features.rs new file mode 100644 index 00000000..82f8e64e --- /dev/null +++ b/tests/repeated_features.rs @@ -0,0 +1,65 @@ +//! A feature list that names a tag more than once takes the last +//! value, as HarfBuzz's map builder does when it merges the entries of +//! a global feature. HarfBuzz 14.5.0 shapes Calibri "office" with +//! `liga=0,liga=1` with the ffi ligature, and "AVAT" with +//! `kern=0,kern=1` kerned; Open Sans has the same features. + +use sigilbuzz::{shape, Blob, Buffer, Face, Feature, Font}; + +const OPEN_SANS: &[u8] = include_bytes!("fixtures/opensans_regular.ttf"); + +fn feature(tag: &[u8; 4], value: u32) -> Feature { + Feature { tag: *tag, value } +} + +fn glyphs(text: &str, features: &[Feature]) -> Vec<(u32, i32, u32)> { + let blob = Blob::new(OPEN_SANS); + let face = Face::parse(&blob, 0).unwrap(); + let font = Font::new(face, 1000.0); + let mut buffer = Buffer::new(); + buffer.push_str(text); + buffer.guess_segment_properties(); + shape(&font, &buffer, features) + .unwrap() + .glyphs + .iter() + .map(|g| (g.glyph_id, g.x_advance, g.cluster)) + .collect() +} + +#[test] +fn the_last_liga_entry_wins() { + let on = glyphs("office", &[]); + let off = glyphs("office", &[feature(b"liga", 0)]); + assert_ne!(on, off, "Open Sans ligates ffi"); + assert_eq!( + glyphs("office", &[feature(b"liga", 0), feature(b"liga", 1)]), + on + ); + assert_eq!( + glyphs("office", &[feature(b"liga", 1), feature(b"liga", 0)]), + off + ); + // Other features in between do not change which entry is last. + let between = [ + feature(b"liga", 0), + feature(b"kern", 1), + feature(b"liga", 1), + ]; + assert_eq!(glyphs("office", &between), on); +} + +#[test] +fn the_last_kern_entry_wins() { + let on = glyphs("AVAT", &[]); + let off = glyphs("AVAT", &[feature(b"kern", 0)]); + assert_ne!(on, off, "Open Sans kerns AV and AT"); + assert_eq!( + glyphs("AVAT", &[feature(b"kern", 0), feature(b"kern", 1)]), + on + ); + assert_eq!( + glyphs("AVAT", &[feature(b"kern", 1), feature(b"kern", 0)]), + off + ); +} From d4bd3d2ccf6935484384db578af69b4976dd1bdb Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 08:39:52 -0800 Subject: [PATCH 02/15] Give glyph_points HarfBuzz's vertical phantoms without vmtx Face::glyph_points, which kerx anchors read, passed no vmtx for a font without one, so the top and bottom phantom points sat at (0, 0). The outline and bounds paths already use HarfBuzz's metrics for that case, a top side bearing of zero and an advance of an em, which put the points at (0, yMax) and (0, yMax - unitsPerEm). --- src/face/outlines.rs | 8 +++++++- tests/glyph_points.rs | 10 ++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/face/outlines.rs b/src/face/outlines.rs index ea298928..9abf66a5 100644 --- a/src/face/outlines.rs +++ b/src/face/outlines.rs @@ -41,6 +41,11 @@ impl<'a> Face<'a> { /// entirely (CFF-only); the caller should treat the missing /// information as "drop the kern silently", the same conservative /// posture sigilbuzz uses for fmt-4 fall-through everywhere else. + /// + /// A font without `vmtx` gets HarfBuzz's vertical metrics for one + /// (see [`Vmtx::missing`]), as the outline and bounds methods do: + /// the top phantom point sits at the top of the glyph's box and the + /// bottom one an em below it. pub fn glyph_points(&self, glyph_id: u16) -> Result>> { if self.record(tag::GLYF).is_none() { return Ok(None); @@ -49,7 +54,8 @@ impl<'a> Face<'a> { let glyf = self.glyf()?; let hmtx = self.hmtx()?; let vmtx = self.vmtx()?; - glyf.glyph_points(&loca, glyph_id, &hmtx, vmtx.as_ref()) + let vmtx = self.phantom_vmtx(vmtx)?; + glyf.glyph_points(&loca, glyph_id, &hmtx, Some(&vmtx)) } /// Returns the design-unit bounding box for `glyph_id`, or diff --git a/tests/glyph_points.rs b/tests/glyph_points.rs index 21fb11fd..0afa9cd6 100644 --- a/tests/glyph_points.rs +++ b/tests/glyph_points.rs @@ -18,6 +18,7 @@ fn open_sans_glyph_points_phantoms_match_hmtx_formula() { let blob = Blob::new(OPEN_SANS); let face = Face::parse(&blob, 0).unwrap(); let hmtx = face.hmtx().unwrap(); + let upem = i16::try_from(face.head().unwrap().units_per_em).unwrap(); // Sample a handful of glyph ids (covering an empty glyph, a // small Latin letter, and a few mid-range glyphs) and assert @@ -48,10 +49,11 @@ fn open_sans_glyph_points_phantoms_match_hmtx_formula() { "pp2.x for gid {gid}: got {pp2:?}, expected x={expected_pp2_x}" ); assert_eq!(pp2.1, 0, "pp2.y must be 0"); - // Open Sans is horizontal-only: no vmtx, so pp3 / pp4 - // collapse to (0, 0). - assert_eq!(pp3, (0, 0), "pp3 collapses without vmtx"); - assert_eq!(pp4, (0, 0), "pp4 collapses without vmtx"); + // Open Sans is horizontal-only. Without vmtx, HarfBuzz + // puts the top phantom point at the top of the box and + // the bottom one an em below it. + assert_eq!(pp3, (0, b.y_max), "pp3 at yMax without vmtx"); + assert_eq!(pp4, (0, b.y_max - upem), "pp4 an em below pp3"); } (None, None) => { // Whitespace glyph: both APIs agree on "no outline". From a44e337402d96908b0cee4fc821ddf284b9fab85 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 10:19:35 -0800 Subject: [PATCH 03/15] Keep lookup accelerators and resolved language systems per Font Every shape() call parsed every subtable of every lookup it considered and walked the script list and language system once per feature query, so a short run paid for the whole font: one Noto Sans KR syllable took about 75 us against HarfBuzz's 4, most of it parsing and dropping the 268 chain-context subtables of one ccmp lookup. A Font now keeps, from its second shaping call on, what HarfBuzz keeps per face: - Per lookup, a set digest of the glyphs its subtables can start at and one per subtable (tables/layout/accel.rs), built from each subtable's primary coverage the first time a run reaches the lookup, under a per-lookup work budget so shared huge coverages cannot blow up. - Per combination of script tags, language and FeatureVariations record, the resolved language system (ot/layout_select.rs), at most 16 per table and each capped in features and lookups. Lookups whose digest misses every glyph of the run are skipped before any parsing or buffer setup, the cursor walk passes glyphs the digest rules out, and subtables are parsed only when a glyph their digest admits reaches them (shape/lazy.rs). A subtable that cannot cover the cursor glyph looks it up first and has no side effects, so skipping it changes nothing; the HarfBuzz subtable-cache rank is still counted over the subtables that parse. A font's first call builds none of this: it reads each lookup's coverages directly, so callers that build a Font per run are not charged for caches they never reuse. The caches are shared by clones, built through a lock-free OnceBox (sync.rs), and hold only values that are a pure function of the font data, so output stays deterministic and Font stays Send + Sync. --- src/font.rs | 15 + src/font/cache.rs | 177 +++++++ src/lib.rs | 1 + src/ot/layout_select.rs | 354 +++++++++++++ src/ot/syllabic/stage.rs | 54 +- src/shape.rs | 1 + src/shape/features.rs | 20 +- src/shape/gpos.rs | 138 ++++-- src/shape/gsub.rs | 161 +++--- src/shape/gsub_parsed.rs | 197 +++----- src/shape/lazy.rs | 132 +++++ src/shape/pipeline.rs | 38 +- src/shape/position.rs | 31 +- src/shape/required.rs | 24 +- src/shape/syllabic.rs | 12 +- src/sync.rs | 181 +++++++ src/tables/gpos/mod.rs | 38 +- src/tables/gsub/mod.rs | 38 +- src/tables/layout/accel.rs | 630 ++++++++++++++++++++++++ src/tables/layout/coverage.rs | 18 + src/tables/layout/feature_variations.rs | 5 + src/tables/layout/mod.rs | 1 + 22 files changed, 1941 insertions(+), 325 deletions(-) create mode 100644 src/font/cache.rs create mode 100644 src/shape/lazy.rs create mode 100644 src/sync.rs create mode 100644 src/tables/layout/accel.rs diff --git a/src/font.rs b/src/font.rs index 7e4ffea8..1e4ebe13 100644 --- a/src/font.rs +++ b/src/font.rs @@ -34,8 +34,12 @@ //! identically to the static default instance, and so does one whose //! coords all round to zero. +mod cache; + use alloc::vec::Vec; +pub(crate) use cache::FontCaches; + use crate::error::Result; use crate::face::Face; use crate::tables::parse::hb_round_to; @@ -72,6 +76,8 @@ pub struct Font<'a> { face: Face<'a>, size: f32, coords: &'a [f32], + /// Lookup accelerators and metrics kept between shaping calls. + caches: FontCaches, } impl<'a> Font<'a> { @@ -88,6 +94,7 @@ impl<'a> Font<'a> { face, size, coords: &[], + caches: FontCaches::new(), } } @@ -119,6 +126,7 @@ impl<'a> Font<'a> { face: self.face.clone(), size, coords: self.coords, + caches: self.caches.clone(), } } @@ -138,6 +146,7 @@ impl<'a> Font<'a> { #[must_use] pub fn with_coords(self, coords: &'a [f32]) -> Self { Self { + caches: self.caches.for_other_coords(), face: self.face, size: self.size, coords, @@ -150,6 +159,12 @@ impl<'a> Font<'a> { pub fn units_per_em(&self) -> Result { Ok(self.face.head()?.units_per_em) } + + /// The lookup accelerators and metrics the font keeps between + /// shaping calls (see the `cache` module). + pub(crate) fn caches(&self) -> &FontCaches { + &self.caches + } } #[cfg(test)] diff --git a/src/font/cache.rs b/src/font/cache.rs new file mode 100644 index 00000000..ac0ef327 --- /dev/null +++ b/src/font/cache.rs @@ -0,0 +1,177 @@ +//! What a [`Font`](super::Font) keeps between shaping calls. +//! +//! HarfBuzz keeps a face's lookup accelerators and shape plans, and a +//! font's metrics caches, for as long as the face and font live, which +//! is why its per-call cost is small. A `Font` here keeps the same +//! kinds of data: +//! +//! - [`FaceCache`], which depends on the font data alone: for GSUB and +//! GPOS, the lookup accelerators (see [`crate::tables::layout::accel`]) +//! and the language systems resolved for each combination of script +//! tags, language and FeatureVariations record a run used (see +//! [`crate::ot::layout_select`]). +//! [`Font::with_coords`](super::Font::with_coords) and +//! [`Font::with_size`](super::Font::with_size) keep it. +//! +//! A font's first shaping call builds none of this: a font shaped once, +//! which many callers build per run, would spend more building it than +//! it saves. From the second call on, each part is built the first time +//! a call needs it. +//! +//! The cache is shared by every clone of the font made after it was +//! built, through an `Arc`, and every part is built through a +//! [`OnceBox`], so a font can be shared by shaping threads without a +//! lock. Whatever a cache holds is +//! a pure function of the font data and coordinates, so a value one +//! thread computes is the value any other would have, and shaping +//! output does not depend on what is cached. +//! +//! Memory is bounded per font. For each of GSUB and GPOS: one pointer +//! per lookup, 24 bytes per subtable of each lookup a run reached, and +//! at most 16 resolved language systems of at most 1024 features and +//! 16384 lookup indices each. + +use alloc::sync::Arc; +use core::fmt; +use core::sync::atomic::{AtomicBool, Ordering}; + +use crate::sync::OnceBox; +use crate::tables::layout::accel::LayoutCache; +use crate::tables::layout::LayoutTable; + +/// The caches of one [`Font`](super::Font). +pub(crate) struct FontCaches { + face: OnceBox>, + /// Whether the font has been shaped with. + used: AtomicBool, +} + +impl FontCaches { + /// Nothing built yet. + pub(crate) const fn new() -> Self { + Self { + face: OnceBox::new(), + used: AtomicBool::new(false), + } + } + + /// Records a shaping call with the font. True when an earlier call + /// used it, or a font it was copied from, so the caches are likely + /// to pay off. + pub(crate) fn note_use(&self) -> bool { + self.used.swap(true, Ordering::Relaxed) + } + + /// What depends on the font data alone, created when first asked + /// for. + pub(crate) fn face(&self) -> &FaceCache { + self.face.get_or_init(|| Arc::new(FaceCache::new())) + } + + /// The caches for a font with the same data at other coordinates: + /// the face cache shared if it exists. + pub(crate) fn for_other_coords(&self) -> Self { + self.clone() + } + + /// Heap bytes the built caches hold. + pub(crate) fn heap_bytes(&self) -> usize { + self.face.get().map_or(0, |c| c.heap_bytes()) + } +} + +/// A box holding the `Arc` `cell` holds, if any. +fn share(cell: &OnceBox>) -> OnceBox> { + match cell.get() { + Some(arc) => OnceBox::with_value(Arc::clone(arc)), + None => OnceBox::new(), + } +} + +impl Clone for FontCaches { + /// Shares whatever is built; what is not built yet is built + /// separately by each copy. + fn clone(&self) -> Self { + Self { + face: share(&self.face), + used: AtomicBool::new(self.used.load(Ordering::Relaxed)), + } + } +} + +impl Default for FontCaches { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for FontCaches { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FontCaches") + .field("heap_bytes", &self.heap_bytes()) + .finish() + } +} + +/// What a font keeps that depends on its data alone. +pub(crate) struct FaceCache { + gsub: OnceBox, + gpos: OnceBox, +} + +impl FaceCache { + const fn new() -> Self { + Self { + gsub: OnceBox::new(), + gpos: OnceBox::new(), + } + } + + /// What the font keeps for its GSUB, which has `lookup_count` + /// lookups. + pub(crate) fn gsub(&self, lookup_count: u16) -> &LayoutCache { + self.gsub + .get_or_init(|| LayoutCache::new(LayoutTable::Gsub, lookup_count)) + } + + /// What the font keeps for its GPOS, which has `lookup_count` + /// lookups. + pub(crate) fn gpos(&self, lookup_count: u16) -> &LayoutCache { + self.gpos + .get_or_init(|| LayoutCache::new(LayoutTable::Gpos, lookup_count)) + } + + fn heap_bytes(&self) -> usize { + core::mem::size_of::() + + [&self.gsub, &self.gpos] + .iter() + .filter_map(|c| c.get()) + .map(|a| core::mem::size_of::() + a.heap_bytes()) + .sum::() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caches_are_shared_once_built() { + let caches = FontCaches::new(); + let empty = caches.clone(); + let face = core::ptr::from_ref(caches.face()); + let shared = caches.clone(); + assert!(core::ptr::eq(shared.face(), face)); + let other = caches.for_other_coords(); + assert!(core::ptr::eq(other.face(), face)); + // A copy made before anything was built builds its own. + assert!(!core::ptr::eq(empty.face(), face)); + assert!(caches.heap_bytes() > 0); + } + + #[test] + fn caches_are_send_and_sync() { + fn check() {} + check::(); + } +} diff --git a/src/lib.rs b/src/lib.rs index ebe96b59..f3e4a537 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,6 +68,7 @@ mod font; mod language; mod owned; mod shape; +mod sync; mod ttc; // Public-but-experimental: the OpenType Layout module exposes the diff --git a/src/ot/layout_select.rs b/src/ot/layout_select.rs index 7a28bac4..a580b9cc 100644 --- a/src/ot/layout_select.rs +++ b/src/ot/layout_select.rs @@ -34,8 +34,11 @@ //! //! [`Script::select_lang_sys`]: crate::tables::layout::Script::select_lang_sys +use alloc::borrow::Cow; +use alloc::collections::BTreeMap; use alloc::vec::Vec; +use crate::sync::OnceBox; use crate::tables::layout::{ActiveFeatures, LangSys, ScriptList}; /// Script tags HarfBuzz falls back to, in order, when none of the @@ -213,6 +216,357 @@ fn sorted(mut indices: Vec) -> Vec { indices } +/// What feature resolution reads of a GSUB or GPOS view: its lists, +/// its language preference, and the font's cache of resolved language +/// systems, if the view has one. +#[derive(Clone, Copy)] +pub(crate) struct LayoutView<'a> { + pub(crate) script_list: ScriptList<'a>, + pub(crate) features: ActiveFeatures<'a>, + pub(crate) language_tags: &'a [[u8; 4]], + pub(crate) maps: Option<&'a FeatureMaps>, +} + +impl LayoutView<'_> { + /// [`feature_lookup_indices`] for this view. + pub(crate) fn feature_lookups( + &self, + tag: [u8; 4], + script_priority: &[[u8; 4]], + ) -> Option> { + match self.map(script_priority).as_deref() { + Some(map) => map.feature_lookups(tag), + None => feature_lookup_indices( + &self.script_list, + &self.features, + self.language_tags, + tag, + script_priority, + ), + } + } + + /// [`lists_feature`] for this view. + pub(crate) fn lists(&self, tag: [u8; 4], script_priority: &[[u8; 4]]) -> bool { + match self.map(script_priority).as_deref() { + Some(map) => map.lists(tag), + None => lists_feature( + &self.script_list, + &self.features, + self.language_tags, + tag, + script_priority, + ), + } + } + + /// [`listed_feature_lookups`] for this view. + pub(crate) fn listed_lookups(&self, tag: [u8; 4], script_priority: &[[u8; 4]]) -> Vec { + match self.map(script_priority).as_deref() { + Some(map) => map.listed_lookups(tag), + None => listed_feature_lookups( + &self.script_list, + &self.features, + self.language_tags, + tag, + script_priority, + ), + } + } + + /// [`required_feature`] for this view. + pub(crate) fn required(&self, script_priority: &[[u8; 4]]) -> Option<([u8; 4], Vec)> { + match self.map(script_priority).as_deref() { + Some(map) => map.required.clone(), + None => required_feature( + &self.script_list, + &self.features, + self.language_tags, + script_priority, + ), + } + } + + /// The resolved language system of a run whose candidate script + /// tags are `script_priority`, from the font's cache: `None` + /// without one, or when the language system is too large to keep. + fn map(&self, script_priority: &[[u8; 4]]) -> Option> { + let maps = self.maps?; + let key = MapKey { + script_priority, + language_tags: self.language_tags, + record: self.features.record(), + }; + maps.get(&key, || FeatureMap::build(self, script_priority)) + } +} + +/// Feature indices a language system may list for its resolution to be +/// kept. Real fonts list a few dozen. +const MAX_MAP_FEATURES: usize = 1024; +/// Lookup indices one resolution may keep, over all its features. +const MAX_MAP_LOOKUPS: usize = 1 << 14; +/// Resolutions one table keeps: one per combination of script tags, +/// language and FeatureVariations record a font is shaped with. +const MAP_SLOTS: usize = 16; + +/// One language system resolved once, so each feature query of a +/// shaping call is a binary search instead of a walk of the script +/// list, the language system and its features. Answers every query +/// exactly as the walk does (see [`feature_lookup_indices`], +/// [`lists_feature`], [`listed_feature_lookups`] and +/// [`required_feature`]). +#[derive(Debug, Clone)] +pub(crate) struct FeatureMap { + /// The table has a usable script and language system. + lang_sys: bool, + /// The tags of the language system's features and of its required + /// feature, sorted. + entries: Vec, + /// [`required_feature`]. + required: Option<([u8; 4], Vec)>, + /// The lookups of the first `vert` feature in the FeatureList, which + /// the global search finds. + global_vert: Option>, +} + +#[derive(Debug, Clone)] +struct MapEntry { + tag: [u8; 4], + /// The language system lists the tag (its required feature does + /// not count). + listed: bool, + /// [`lang_sys_lookups`] for the tag. + lookups: Option>, + /// The lookups of the first listed feature with the tag, as + /// [`listed_feature_lookups`] reads them. + listed_lookups: Vec, +} + +impl FeatureMap { + /// Resolves the language system `view` picks for `script_priority`, + /// or `None` when it lists too many features or lookups to keep. + fn build(view: &LayoutView<'_>, script_priority: &[[u8; 4]]) -> Option { + let features = &view.features; + let lookups_of = |index: u16| -> Option> { + features + .get(index) + .map(|(_, f)| sorted(f.lookup_indices().collect())) + }; + let global_vert = features + .find(*b"vert") + .map(|index| lookups_of(index).unwrap_or_default()); + let lang_sys = select_lang_sys(&view.script_list, view.language_tags, script_priority); + let Some(lang_sys) = lang_sys else { + return Some(Self { + lang_sys: false, + entries: Vec::new(), + required: None, + global_vert, + }); + }; + // The first listed feature of each tag, and the first one whose + // table reads. + let mut firsts: BTreeMap<[u8; 4], (Option, Option)> = BTreeMap::new(); + for (n, index) in lang_sys.feature_indices().enumerate() { + if n >= MAX_MAP_FEATURES { + return None; + } + let Some(tag) = features.tag(index) else { + continue; + }; + let first = firsts.entry(tag).or_insert((Some(index), None)); + if first.1.is_none() && features.get(index).is_some() { + first.1 = Some(index); + } + } + let required_index = lang_sys.required_feature_index(); + let required_tag = required_index.and_then(|index| features.tag(index)); + if let Some(tag) = required_tag { + firsts.entry(tag).or_insert((None, None)); + } + let required = required_index.and_then(|index| { + let (tag, f) = features.get(index)?; + Some((tag, sorted(f.lookup_indices().collect()))) + }); + let mut total = 0usize; + let mut entries = Vec::with_capacity(firsts.len()); + for (tag, (first_listed, first_read)) in firsts { + // `lang_sys_lookups`: the required feature when it has the + // tag and reads, and the first listed one that reads. + let from_required = required_index + .filter(|_| required_tag == Some(tag)) + .and_then(|index| features.get(index)); + let from_listed = first_read.and_then(|index| features.get(index)); + let lookups = (from_required.is_some() || from_listed.is_some()).then(|| { + sorted( + from_required + .into_iter() + .chain(from_listed) + .flat_map(|(_, f)| f.lookup_indices()) + .collect(), + ) + }); + let listed_lookups = first_listed.and_then(lookups_of).unwrap_or_default(); + total = total + .saturating_add(lookups.as_ref().map_or(0, Vec::len)) + .saturating_add(listed_lookups.len()); + if total > MAX_MAP_LOOKUPS { + return None; + } + entries.push(MapEntry { + tag, + listed: first_listed.is_some(), + lookups, + listed_lookups, + }); + } + Some(Self { + lang_sys: true, + entries, + required, + global_vert, + }) + } + + fn entry(&self, tag: [u8; 4]) -> Option<&MapEntry> { + self.entries + .binary_search_by_key(&tag, |e| e.tag) + .ok() + .and_then(|i| self.entries.get(i)) + } + + fn feature_lookups(&self, tag: [u8; 4]) -> Option> { + if let Some(lookups) = self.entry(tag).and_then(|e| e.lookups.as_ref()) { + return Some(lookups.clone()); + } + if GLOBAL_SEARCH_FEATURES.contains(&tag) { + if let Some(lookups) = &self.global_vert { + return Some(lookups.clone()); + } + } + self.lang_sys.then(Vec::new) + } + + fn lists(&self, tag: [u8; 4]) -> bool { + self.entry(tag).is_some_and(|e| e.listed) + } + + fn listed_lookups(&self, tag: [u8; 4]) -> Vec { + match self.entry(tag).filter(|e| e.listed) { + Some(e) => e.listed_lookups.clone(), + None if GLOBAL_SEARCH_FEATURES.contains(&tag) => { + self.global_vert.clone().unwrap_or_default() + } + None => Vec::new(), + } + } + + /// Heap bytes the map holds. + fn heap_bytes(&self) -> usize { + let lookups = |v: &Vec| v.capacity() * 2; + self.entries.capacity() * core::mem::size_of::() + + self + .entries + .iter() + .map(|e| e.lookups.as_ref().map_or(0, lookups) + lookups(&e.listed_lookups)) + .sum::() + + self.required.as_ref().map_or(0, |(_, v)| lookups(v)) + + self.global_vert.as_ref().map_or(0, lookups) + } +} + +/// What a [`FeatureMap`] depends on besides the table. +struct MapKey<'k> { + script_priority: &'k [[u8; 4]], + language_tags: &'k [[u8; 4]], + record: Option, +} + +/// A kept [`FeatureMap`] with its key; `None` for a language system too +/// large to keep, which is then walked each time. +struct KeyedMap { + script_priority: Vec<[u8; 4]>, + language_tags: Vec<[u8; 4]>, + record: Option, + map: Option, +} + +impl KeyedMap { + fn matches(&self, key: &MapKey<'_>) -> bool { + self.script_priority == key.script_priority + && self.language_tags == key.language_tags + && self.record == key.record + } +} + +/// The language systems one table has resolved, kept by a +/// [`crate::Font`] across shaping calls: at most [`MAP_SLOTS`] of them. +/// Past that, a run's language system is walked each time, as it is +/// without a cache. +pub(crate) struct FeatureMaps { + slots: [OnceBox; MAP_SLOTS], +} + +impl FeatureMaps { + /// No language system resolved yet. + pub(crate) fn new() -> Self { + Self { + slots: core::array::from_fn(|_| OnceBox::new()), + } + } + + /// The map for `key`, built with `build` the first time, or `None` + /// when it is too large to keep. + fn get( + &self, + key: &MapKey<'_>, + build: impl Fn() -> Option, + ) -> Option> { + for slot in &self.slots { + let keyed = match slot.get() { + Some(keyed) => keyed, + None => slot.get_or_init(|| KeyedMap { + script_priority: key.script_priority.to_vec(), + language_tags: key.language_tags.to_vec(), + record: key.record, + map: build(), + }), + }; + if keyed.matches(key) { + return keyed.map.as_ref().map(Cow::Borrowed); + } + } + build().map(Cow::Owned) + } + + /// Heap bytes the kept maps hold. + pub(crate) fn heap_bytes(&self) -> usize { + self.slots + .iter() + .filter_map(OnceBox::get) + .map(|k| { + core::mem::size_of::() + + (k.script_priority.capacity() + k.language_tags.capacity()) * 4 + + k.map.as_ref().map_or(0, FeatureMap::heap_bytes) + }) + .sum() + } +} + +impl Default for FeatureMaps { + fn default() -> Self { + Self::new() + } +} + +impl core::fmt::Debug for FeatureMaps { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let kept = self.slots.iter().filter(|s| s.get().is_some()).count(); + f.debug_struct("FeatureMaps").field("kept", &kept).finish() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ot/syllabic/stage.rs b/src/ot/syllabic/stage.rs index 4f43cb17..1896a152 100644 --- a/src/ot/syllabic/stage.rs +++ b/src/ot/syllabic/stage.rs @@ -130,15 +130,11 @@ pub(crate) fn feature_lookups( tag: [u8; 4], script_priority: &[[u8; 4]], ) -> Vec { - let gsub = runner.gsub(); - crate::ot::layout_select::feature_lookup_indices( - gsub.script_list(), - &gsub.features(), - gsub.language_tags(), - tag, - script_priority, - ) - .unwrap_or_default() + runner + .gsub() + .layout_view() + .feature_lookups(tag, script_priority) + .unwrap_or_default() } /// True when the language system the run selects lists `tag` and the @@ -155,15 +151,7 @@ pub(crate) fn has_feature( tag: [u8; 4], script_priority: &[[u8; 4]], ) -> bool { - let gsub = runner.gsub(); - !user_disabled(features, tag) - && crate::ot::layout_select::lists_feature( - gsub.script_list(), - &gsub.features(), - gsub.language_tags(), - tag, - script_priority, - ) + !user_disabled(features, tag) && runner.gsub().layout_view().lists(tag, script_priority) } /// Adds the caller's features to a shaper's last stage, where HarfBuzz @@ -242,18 +230,13 @@ fn stage_lookups( features: &[StageFeature], user: &[Feature], ) -> Vec { - let gsub = runner.gsub(); - let active = gsub.features(); + let view = runner.gsub().layout_view(); let enabled = || features.iter().filter(|f| !user_disabled(user, f.tag)); - let required = crate::ot::layout_select::required_feature( - gsub.script_list(), - &active, - gsub.language_tags(), - script_priority, - ) - .filter(|(tag, _)| enabled().any(|f| f.tag == *tag)) - .map(|(_, indices)| indices) - .unwrap_or_default(); + let required = view + .required(script_priority) + .filter(|(tag, _)| enabled().any(|f| f.tag == *tag)) + .map(|(_, indices)| indices) + .unwrap_or_default(); let mut lookups: Vec = Vec::new(); let mut add = |index: u16, mask: u32, joiners: Joiners, per_syllable: bool| match lookups .iter_mut() @@ -274,13 +257,7 @@ fn stage_lookups( add(index, GLOBAL_MASK, Joiners::AUTO, false); } for f in enabled() { - let listed = crate::ot::layout_select::listed_feature_lookups( - gsub.script_list(), - &active, - gsub.language_tags(), - f.tag, - script_priority, - ); + let listed = view.listed_lookups(f.tag, script_priority); let per_syllable = f.flags.contains(FeatureFlags::PER_SYLLABLE); for index in listed { add(index, f.mask, f.flags.joiners(), per_syllable); @@ -362,6 +339,11 @@ pub(crate) fn apply_stage( }) .collect(); for lookup in lookups { + // A lookup none of whose subtables can start at a glyph of the + // run leaves the glyphs, and so their slots, as they are. + if !runner.may_apply(lookup.index, glyphs) { + continue; + } let joiners = lookup.joiners; let substituted: Vec; { diff --git a/src/shape.rs b/src/shape.rs index 366debf1..78ee6477 100644 --- a/src/shape.rs +++ b/src/shape.rs @@ -131,6 +131,7 @@ mod hangul; mod ignorables; mod joiners; mod kern; +mod lazy; mod lig; mod native_direction; mod normalize; diff --git a/src/shape/features.rs b/src/shape/features.rs index 221f6664..be79ed5f 100644 --- a/src/shape/features.rs +++ b/src/shape/features.rs @@ -6,7 +6,10 @@ use alloc::collections::BTreeMap; use alloc::vec::Vec; use super::arabic_joining::Action; -use super::gsub::{apply_gsub_lookup, apply_gsub_lookups_masked, apply_gsub_stage, StageLookup}; +use super::gsub::{ + apply_gsub_lookup, apply_gsub_lookups_masked, apply_gsub_stage, gsub_lookup_may_apply, + StageLookup, +}; use super::joiners::FeatureFlags; use super::{feature_disabled, feature_enabled, Feature, JoinerTable, LookupBudget}; use crate::buffer::Glyph; @@ -431,7 +434,12 @@ pub(super) fn apply_arabic_positional_features( else { continue; }; - if lookup_indices.is_empty() { + // Lookups that cannot start at any glyph leave the run as it + // is, so the mask is not worth building. + if !lookup_indices + .iter() + .any(|&index| gsub_lookup_may_apply(gsub, index, glyphs)) + { continue; } let mask: Vec = glyphs.iter().map(|g| action.is_on(g)).collect(); @@ -451,13 +459,7 @@ fn lookup_indices_for_feature_in_scripts( tag: [u8; 4], script_priority: &[[u8; 4]], ) -> Option> { - crate::ot::layout_select::feature_lookup_indices( - gsub.script_list(), - &gsub.features(), - gsub.language_tags(), - tag, - script_priority, - ) + gsub.layout_view().feature_lookups(tag, script_priority) } /// Asks "would feature `tag`'s lookups substitute starting at the diff --git a/src/shape/gpos.rs b/src/shape/gpos.rs index c413657a..28707386 100644 --- a/src/shape/gpos.rs +++ b/src/shape/gpos.rs @@ -32,6 +32,7 @@ use alloc::vec::Vec; use super::attach::{self, Attach, AttachSubtable, LookupCx}; use super::glyph_flags::FlagCx; +use super::lazy::LazySubtables; use super::{ feature_disabled, filter_for_lookup, resolve_extension, Feature, LookupBudget, VarCtx, MAX_NESTED_DEPTH, @@ -41,7 +42,8 @@ use crate::tables::gdef::Gdef; use crate::tables::gpos::{ lookup_type as gpos_lt, ChainContextPos, ContextPos, PairPos, SinglePos, ValueRecord, }; -use crate::tables::layout::skip_iter::{apply_nested as apply_records, MaySkip, SUBTABLE_CACHES}; +use crate::tables::layout::accel::Accel; +use crate::tables::layout::skip_iter::{apply_nested as apply_records, MaySkip}; use crate::tables::layout::{ InputMatch, Joiners, LayoutTable, Lookup, MatchContext, MatchGlyph, SequenceLookupRecord, SkipRules, @@ -182,37 +184,40 @@ enum PosSubtable<'a> { Chain(ChainContextPos<'a>), } -/// Parses a lookup's subtables, unwrapping Extension subtables and -/// dropping the ones that fail to parse. -fn parse_subtables<'a>(lookup: &Lookup<'a>) -> Vec> { +impl PosSubtable<'_> { + /// True for a class-based context subtable, the one kind whose + /// matching reads [`MatchContext::rule_set_digests`]. + fn reads_rule_set_digests(&self) -> bool { + matches!( + self, + Self::Context(ContextPos::Format2(_)) | Self::Chain(ChainContextPos::Format2(_)) + ) + } +} + +/// A GPOS lookup's subtables, each parsed the first time a glyph +/// reaches it (see the `lazy` module). +type PosSubtables<'a> = LazySubtables<'a, PosSubtable<'a>>; + +/// Parses subtable `sub_idx` of `lookup`, unwrapping an Extension. +/// `None` for a subtable that fails to parse, which the shaper drops. +fn parse_subtable<'a>(lookup: &Lookup<'a>, sub_idx: u16) -> Option> { + let bytes = lookup.subtable_bytes(sub_idx)?; let raw_lt = lookup.lookup_type(); - let mut out = Vec::with_capacity(usize::from(lookup.subtable_count())); - for sub_idx in 0..lookup.subtable_count() { - let Some(bytes) = lookup.subtable_bytes(sub_idx) else { - continue; - }; - let (lt, inner) = if raw_lt == gpos_lt::EXTENSION { - match resolve_extension(bytes) { - Some(pair) => pair, - None => continue, - } - } else { - (raw_lt, bytes) - }; - let parsed = match lt { - gpos_lt::SINGLE_ADJUSTMENT => SinglePos::parse(inner) - .ok() - .map(|s| PosSubtable::Single(s, inner)), - gpos_lt::PAIR_ADJUSTMENT => PairPos::parse(inner).ok().map(PosSubtable::Pair), - gpos_lt::CONTEXT => ContextPos::parse(inner).ok().map(PosSubtable::Context), - gpos_lt::CHAINED_CONTEXT => ChainContextPos::parse(inner).ok().map(PosSubtable::Chain), - lt => AttachSubtable::parse(lt, inner).map(PosSubtable::Attach), - }; - if let Some(p) = parsed { - out.push(p); - } + let (lt, inner) = if raw_lt == gpos_lt::EXTENSION { + resolve_extension(bytes)? + } else { + (raw_lt, bytes) + }; + match lt { + gpos_lt::SINGLE_ADJUSTMENT => SinglePos::parse(inner) + .ok() + .map(|s| PosSubtable::Single(s, inner)), + gpos_lt::PAIR_ADJUSTMENT => PairPos::parse(inner).ok().map(PosSubtable::Pair), + gpos_lt::CONTEXT => ContextPos::parse(inner).ok().map(PosSubtable::Context), + gpos_lt::CHAINED_CONTEXT => ChainContextPos::parse(inner).ok().map(PosSubtable::Chain), + lt => AttachSubtable::parse(lt, inner).map(PosSubtable::Attach), } - out } /// Per-lookup state shared by every subtable of one lookup: its flags, @@ -243,25 +248,44 @@ fn apply_lookup( let Some(lookup) = cx.gpos.lookup_list().get(stage.index) else { return; }; - let subtables = parse_subtables(&lookup); - if subtables.is_empty() { + // A lookup none of whose subtables can start at a glyph of the run + // changes nothing, and HarfBuzz skips it; positioning never changes + // glyph ids, so the run's ids decide for the whole walk. + let accel = cx.gpos.lookup_accel(stage.index, &lookup); + if !accel.may_apply(run.iter().map(|g| g.id)) { return; } + let mut subtables = LazySubtables::new(lookup, parse_subtable); let state = LookupState::new(&lookup, cx.gdef, stage.joiners, stage.index); // Each lookup starts with no remembered mark base, as in HarfBuzz. att.reset_base_cache(); let mut i = 0; while i < glyphs.len() { + // A glyph the lookup flags skip, or one no subtable can start + // at (every subtable first looks it up in its coverage), is + // passed over. if run .get(i) - .is_some_and(|&g| state.mcx.filter().is_skipped(g)) + .is_some_and(|&g| !accel.may_have_cheaply(g.id) || state.mcx.filter().is_skipped(g)) { i += 1; continue; } - i = match apply_subtables_at(cx, &subtables, &state, glyphs, att, run, i, 0, budget) { - Some(next) => next.max(i + 1), - None => i + 1, + let at = i; + i = match apply_subtables_at( + cx, + &mut subtables, + &accel, + &state, + glyphs, + att, + run, + at, + 0, + budget, + ) { + Some(next) => next.max(at + 1), + None => at + 1, }; } } @@ -290,17 +314,38 @@ fn apply_lookup_at( let Some(lookup) = cx.gpos.lookup_list().get(lookup_index) else { return; }; - let subtables = parse_subtables(&lookup); + let accel = cx.gpos.lookup_accel(lookup_index, &lookup); + // No subtable can start at the glyph: nothing to parse or apply. + if !accel.may_have_cheaply(glyphs[at].glyph_id as u16) { + return; + } + let mut subtables = LazySubtables::new(lookup, parse_subtable); let state = LookupState::new(&lookup, cx.gdef, joiners, lookup_index); - apply_subtables_at(cx, &subtables, &state, glyphs, att, run, at, depth, budget); + let (state, accel) = (&state, &accel); + apply_subtables_at( + cx, + &mut subtables, + accel, + state, + glyphs, + att, + run, + at, + depth, + budget, + ); } /// Tries each subtable at `at` in order. Returns where the cursor goes -/// next when one applies, `None` when none does. +/// next when one applies, `None` when none does. A subtable `accel` +/// shows cannot cover the glyph at `at` is passed over unparsed: it +/// starts by looking the glyph up in that coverage, so it would not +/// have applied. #[allow(clippy::too_many_arguments)] fn apply_subtables_at( cx: &GposCx<'_>, - subtables: &[PosSubtable<'_>], + subtables: &mut PosSubtables<'_>, + accel: &Accel<'_, '_>, state: &LookupState<'_>, glyphs: &mut [Glyph], att: &mut Attach<'_>, @@ -310,8 +355,17 @@ fn apply_subtables_at( budget: &mut LookupBudget, ) -> Option { let horizontal = att.direction.is_horizontal(); - for (k, sub) in subtables.iter().enumerate() { - let mcx = &state.mcx.with_rule_set_digests(k < SUBTABLE_CACHES); + let id = glyphs.get(at)?.glyph_id as u16; + for index in 0..subtables.len() { + if !accel.subtable_may_start(subtables.is_parsed(index), usize::from(index), id) { + continue; + } + let Some((sub, digests)) = + subtables.get_with_digests(index, PosSubtable::reads_rule_set_digests) + else { + continue; + }; + let mcx = &state.mcx.with_rule_set_digests(digests); let next = match sub { PosSubtable::Single(sp, base) => { let glyph = glyphs.get_mut(at)?; diff --git a/src/shape/gsub.rs b/src/shape/gsub.rs index ac60a2db..88789ef3 100644 --- a/src/shape/gsub.rs +++ b/src/shape/gsub.rs @@ -21,16 +21,14 @@ use alloc::vec::Vec; use super::gsub_buffer::GsubBuffer; use super::gsub_parsed::{ - apply_parsed_lookup_at, cursor_in_digest, filter_for_lookup, lookup_might_apply, - parse_lookup_subtables, parsed_has_full_digest, ParsedGsubSubtable, + apply_parsed_lookup_at, filter_for_lookup, lazy_subtables, GsubSubtables, ParsedGsubSubtable, }; use super::joiners::FeatureFlags; use super::{resolve_extension, LookupBudget, MAX_NESTED_DEPTH}; use crate::buffer::{unicode_prop, Glyph}; use crate::tables::gdef::Gdef; -use crate::tables::gsub::{ - lookup_type as gsub_lt, ChainContextAny, Context as GsubContext, ReverseChain, -}; +use crate::tables::gsub::{lookup_type as gsub_lt, ChainContextAny, Context as GsubContext}; +use crate::tables::layout::accel::Accel; use crate::tables::layout::skip_iter::apply_nested; use crate::tables::layout::{ InputMatch, LayoutTable, Lookup, MatchContext, MatchGlyph, SequenceLookupRecord, @@ -97,6 +95,14 @@ pub(super) fn apply_gsub_lookups_masked( flags: FeatureFlags, budget: &mut LookupBudget, ) -> Vec { + // Lookups that cannot start at any glyph change nothing, so when + // none can, the run is left as it is without building a buffer. + if !lookups + .iter() + .any(|&index| gsub_lookup_may_apply(gsub, index, glyphs)) + { + return alloc::vec![false; glyphs.len()]; + } let cx = GsubCx { gsub, gdef, flags }; let mut buf = GsubBuffer::new( core::mem::take(glyphs), @@ -106,7 +112,7 @@ pub(super) fn apply_gsub_lookups_masked( ); for &index in lookups { if let Some(lookup) = gsub.lookup_list().get(index) { - apply_lookup_to_buffer(&cx, &lookup, &mut buf, 0, budget); + apply_lookup_to_buffer(&cx, index, lookup, &mut buf, 0, budget); } } let (out, substituted) = buf.into_glyphs_and_substituted(); @@ -152,7 +158,7 @@ pub(super) fn apply_gsub_stage( flags: l.flags, }; buf.set_mask_active(l.masked); - apply_lookup_to_buffer(&cx, &lookup, &mut buf, l.alternate, budget); + apply_lookup_to_buffer(&cx, l.index, lookup, &mut buf, l.alternate, budget); } } *glyphs = buf.into_glyphs(); @@ -187,6 +193,9 @@ pub(super) fn apply_gsub_lookup( let Some(lookup) = gsub.lookup_list().get(lookup_idx) else { return; }; + if !lookup_may_apply(&gsub.lookup_accel(lookup_idx, &lookup), glyphs) { + return; + } let cx = GsubCx { gsub, gdef, flags }; let mut buf = GsubBuffer::new( core::mem::take(glyphs), @@ -194,58 +203,78 @@ pub(super) fn apply_gsub_lookup( gsub.cluster_level(), gsub.unsafe_to_concat(), ); - apply_lookup_to_buffer(&cx, &lookup, &mut buf, alternate_index, budget); + apply_lookup_to_buffer(&cx, lookup_idx, lookup, &mut buf, alternate_index, budget); *glyphs = buf.into_glyphs(); } -/// One pass of `lookup` over `buf`, HarfBuzz's -/// `apply_string`: `apply_forward`, whose cursor stops at each glyph -/// the feature is on at and the lookup flags keep, and moves on by one -/// wherever no subtable applies, or `apply_backward` for a reverse -/// chaining lookup. +/// True when some glyph of `glyphs` may start a subtable of the lookup +/// `accel` belongs to. When none can, the lookup changes nothing, so +/// HarfBuzz skips it (`hb_ot_map_t::apply` tests the buffer's digest) +/// and so does every pass here. +pub(super) fn lookup_may_apply(accel: &Accel<'_, '_>, glyphs: &[Glyph]) -> bool { + accel.may_apply(glyphs.iter().map(|g| g.glyph_id as u16)) +} + +/// True when GSUB lookup `index` may change `glyphs` (see +/// [`lookup_may_apply`]); false for a lookup the table does not have. +pub(crate) fn gsub_lookup_may_apply(gsub: &Gsub<'_>, index: u16, glyphs: &[Glyph]) -> bool { + gsub.lookup_list() + .get(index) + .is_some_and(|lookup| lookup_may_apply(&gsub.lookup_accel(index, &lookup), glyphs)) +} + +/// One pass of `lookup`, lookup `index` of the table, over `buf`, +/// HarfBuzz's `apply_string`: `apply_forward`, whose cursor stops at +/// each glyph the feature is on at, the lookup flags keep, and the +/// lookup's accelerator admits, and moves on by one wherever no +/// subtable applies, or `apply_backward` for a reverse chaining lookup. fn apply_lookup_to_buffer( cx: &GsubCx<'_>, - lookup: &Lookup<'_>, + index: u16, + lookup: Lookup<'_>, buf: &mut GsubBuffer, alternate_index: u16, budget: &mut LookupBudget, ) { - // Pre-parse subtables once so the cursor walk below doesn't - // re-parse them at every position. ChainContextAny / Context / - // Ligature parsers each allocate three or four `Vec`s for their - // coverage / substitution arrays; doing that per cursor on a 80- - // glyph Devanagari run is what made the bench look like a - // quadratic explosion. - let parsed = parse_lookup_subtables(lookup, lookup.lookup_type()); // Run-level "would_apply" precheck. If no glyph in the run can - // possibly trigger any subtable's primary coverage, the cursor - // walk has nothing to do. - if parsed.is_empty() || !lookup_might_apply(&parsed, buf.glyphs()) { + // possibly start any subtable, the cursor walk has nothing to do, + // and no subtable is parsed. + let accel = cx.gsub.lookup_accel(index, &lookup); + if !lookup_may_apply(&accel, buf.glyphs()) { return; } - let mcx = cx.match_cx(lookup); - if effective_type(lookup) == gsub_lt::REVERSE_CHAINED { - apply_reverse_chain(&parsed, buf, &mcx); + // Each subtable is parsed the first time a glyph its coverage may + // hold reaches it, and kept for the rest of the walk. Parsing a + // context subtable allocates its rule arrays, so parsing them all + // at every cursor made Devanagari look quadratic, and parsing them + // all up front cost a short run more than shaping it. + let mut subtables = lazy_subtables(lookup); + let mcx = cx.match_cx(&lookup); + if effective_type(&lookup) == gsub_lt::REVERSE_CHAINED { + apply_reverse_chain(&mut subtables, &accel, buf, &mcx); return; } - // With the "digest" path the cursor only stops at glyphs in the - // union of the subtables' primary coverages. It falls back to - // visiting every position when a subtable's primary coverage - // isn't a single `Coverage` table (chain-context format 1/2). - let use_digest = parsed_has_full_digest(&parsed); + // The cursor only stops at glyphs the lookup's digest admits: the + // others start no subtable. buf.clear_output(); while let Some(&g) = buf.cur() { let m = MatchGlyph::from(&g); - if (use_digest && !cursor_in_digest(&parsed, m.id)) - || !buf.cur_in_mask() - || mcx.filter().is_skipped(m) - { + if !accel.may_have(m.id) || !buf.cur_in_mask() || mcx.filter().is_skipped(m) { buf.next_glyph(); continue; } let (cursor, len) = (buf.cursor(), buf.len()); - let applied = - apply_parsed_lookup_at(cx, &parsed, &mcx, buf, 0, alternate_index, false, budget); + let applied = apply_parsed_lookup_at( + cx, + &mut subtables, + &accel, + &mcx, + buf, + 0, + alternate_index, + false, + budget, + ); // A subtable that matches but produces zero substitutions // (common in Amiri rlig: a context with `SubstCount=0` is a // "no-op match" that blocks later subtables at this cursor) @@ -279,12 +308,29 @@ fn apply_gsub_lookup_at( return None; } let lookup = cx.gsub.lookup_list().get(lookup_idx)?; - let parsed = parse_lookup_subtables(&lookup, lookup.lookup_type()); + let accel = cx.gsub.lookup_accel(lookup_idx, &lookup); + // A glyph no subtable can start at: nothing applies, and nothing + // needs parsing. + if !accel.may_have(buf.cur()?.glyph_id as u16) { + return None; + } + let mut subtables = lazy_subtables(lookup); let mcx = cx.match_cx(&lookup); // Nested alternate lookups always pick index 0: feature // value-based selection is a top-level concept and does not // propagate into a recursed lookup. - apply_parsed_lookup_at(cx, &parsed, &mcx, buf, depth, 0, true, budget).then_some(()) + apply_parsed_lookup_at( + cx, + &mut subtables, + &accel, + &mcx, + buf, + depth, + 0, + true, + budget, + ) + .then_some(()) } /// A GSUB contextual subtable at the cursor: matches the rule and runs @@ -401,34 +447,39 @@ pub(super) fn substitute_glyph(glyph: &mut Glyph, gid: u16) { /// `apply_backward`: the cursor walks the run right to left, and at /// every glyph the lookup flags keep, the first subtable whose context /// matches substitutes it. Glyphs after the cursor are already -/// substituted, which is what the lookahead sees. +/// substituted, which is what the lookahead sees. Subtables that are +/// not reverse chaining ones (an Extension can wrap another type) never +/// apply here, and a subtable whose coverage `accel` shows cannot hold +/// the glyph is passed over unparsed. fn apply_reverse_chain( - parsed: &[ParsedGsubSubtable<'_>], + subtables: &mut GsubSubtables<'_>, + accel: &Accel<'_, '_>, buf: &mut GsubBuffer, mcx: &MatchContext<'_>, ) { - let subtables: Vec<&ReverseChain<'_>> = parsed - .iter() - .filter_map(|s| match s { - ParsedGsubSubtable::ReverseChained(rc) => Some(rc), - _ => None, - }) - .collect(); - if subtables.is_empty() { - return; - } buf.sync(); for i in (0..buf.len()).rev() { let Some(g) = buf.get(i) else { continue; }; + let id = g.glyph_id as u16; if !buf.in_mask_at(i) || mcx.filter().is_skipped(MatchGlyph::from(g)) { continue; } let mut ops = buf.take_flag_ops(); - let substitute = subtables - .iter() - .find_map(|rc| rc.apply_at_in(&*buf, i, mcx, &mut ops)); + let mut substitute = None; + for index in 0..subtables.len() { + if !accel.subtable_may_start(subtables.is_parsed(index), usize::from(index), id) { + continue; + } + let Some(ParsedGsubSubtable::ReverseChained(rc)) = subtables.get(index) else { + continue; + }; + substitute = rc.apply_at_in(&*buf, i, mcx, &mut ops); + if substitute.is_some() { + break; + } + } buf.apply_flag_ops(ops); if let Some(out) = substitute { buf.replace_glyph_at(i, out); diff --git a/src/shape/gsub_parsed.rs b/src/shape/gsub_parsed.rs index 66eeedb7..fa1b775f 100644 --- a/src/shape/gsub_parsed.rs +++ b/src/shape/gsub_parsed.rs @@ -1,29 +1,28 @@ -//! Pre-parsed GSUB subtables: the parse-once cache a lookup's cursor -//! walk reuses, the coverage digests that let it skip positions, -//! and the per-cursor dispatch over the cache. - -use alloc::vec::Vec; +//! Parsed GSUB subtables: parsing one subtable, and the per-cursor +//! dispatch over a lookup's subtables, which parses each the first +//! time a glyph its accelerator admits reaches it (see the `lazy` +//! module). use super::gsub::{apply_gsub_chain_context_at, apply_gsub_context_at, GsubCx}; use super::gsub_buffer::GsubBuffer; +use super::lazy::LazySubtables; use super::{lig, resolve_extension, LookupBudget}; -use crate::buffer::Glyph; use crate::tables::gdef::Gdef; use crate::tables::gsub::{ lookup_type as gsub_lt, Alternate, ChainContextAny, Context as GsubContext, Ligature, Multiple, ReverseChain, Single, }; -use crate::tables::layout::skip_iter::SUBTABLE_CACHES; +use crate::tables::layout::accel::Accel; use crate::tables::layout::{Lookup, MatchContext, MatchFilter}; -/// One pre-parsed GSUB subtable, ready to drive a cursor walk. +/// One parsed GSUB subtable, ready to drive a cursor walk. /// -/// `apply_gsub_lookup` parses the lookup's subtables once into this -/// enum and reuses the parsed views across every cursor step. Without -/// the cache, ChainContext / Context format-3 parsing allocates three -/// or four `Vec` and a `Vec` on every -/// cursor: `O(N * subtables)` allocations for a single feature, the -/// lion's share of the Devanagari regression. +/// A lookup's subtables are parsed at most once per application and +/// reused across every cursor step. Without that, ChainContext / +/// Context format-3 parsing allocates three or four `Vec` +/// and a `Vec` on every cursor: `O(N * subtables)` +/// allocations for a single feature, the lion's share of the +/// Devanagari regression. pub(super) enum ParsedGsubSubtable<'a> { Single(Single<'a>), Multiple(Multiple<'a>), @@ -34,114 +33,61 @@ pub(super) enum ParsedGsubSubtable<'a> { ReverseChained(ReverseChain<'a>), } -/// Parses the subtables of a single `Lookup`, handling the Extension -/// type-7 unwrap so the caller never sees raw lookup type 7. Returns -/// the parsed list in spec order; subtables that fail to parse are -/// dropped. -pub(super) fn parse_lookup_subtables<'a>( - lookup: &Lookup<'a>, - raw_lt: u16, -) -> Vec> { - let count = lookup.subtable_count() as usize; - let mut out: Vec> = Vec::with_capacity(count); - for sub_idx in 0..lookup.subtable_count() { - let Some(bytes) = lookup.subtable_bytes(sub_idx) else { - continue; - }; - let (effective_lt, inner_bytes) = if raw_lt == gsub_lt::EXTENSION { - match resolve_extension(bytes) { - Some(pair) => pair, - None => continue, - } - } else { - (raw_lt, bytes) - }; - let parsed = match effective_lt { - gsub_lt::SINGLE => Single::parse(inner_bytes) - .ok() - .map(ParsedGsubSubtable::Single), - gsub_lt::MULTIPLE => Multiple::parse(inner_bytes) - .ok() - .map(ParsedGsubSubtable::Multiple), - gsub_lt::ALTERNATE => Alternate::parse(inner_bytes) - .ok() - .map(ParsedGsubSubtable::Alternate), - gsub_lt::LIGATURE => Ligature::parse(inner_bytes) - .ok() - .map(ParsedGsubSubtable::Ligature), - gsub_lt::CONTEXT => GsubContext::parse(inner_bytes) - .ok() - .map(ParsedGsubSubtable::Context), - gsub_lt::CHAINED_CONTEXT => ChainContextAny::parse(inner_bytes) - .ok() - .map(ParsedGsubSubtable::ChainContext), - gsub_lt::REVERSE_CHAINED => ReverseChain::parse(inner_bytes) - .ok() - .map(ParsedGsubSubtable::ReverseChained), - _ => None, - }; - if let Some(p) = parsed { - out.push(p); - } +impl ParsedGsubSubtable<'_> { + /// True for a class-based context subtable, the one kind whose + /// matching reads [`MatchContext::rule_set_digests`]. + fn reads_rule_set_digests(&self) -> bool { + matches!( + self, + Self::Context(GsubContext::Format2(_)) + | Self::ChainContext(ChainContextAny::Format2(_)) + ) } - out } -/// Returns the "primary" coverage table for a parsed subtable: the -/// coverage on the cursor glyph. Used by the run-level `would_apply` -/// precheck and by the cursor digest in `apply_gsub_lookup`. `None` -/// means the subtable's coverage isn't a single `Coverage` table -/// (chain-context format 1/2, reverse-chain, ...) and the cursor -/// walker has to fall back to per-position dispatch. -fn primary_coverage_of<'a, 'b>( - sub: &'b ParsedGsubSubtable<'a>, -) -> Option<&'b crate::tables::layout::Coverage<'a>> { - match sub { - ParsedGsubSubtable::Single( - Single::Delta { coverage, .. } | Single::Explicit { coverage, .. }, - ) => Some(coverage), - ParsedGsubSubtable::Multiple(m) => Some(m.coverage()), - ParsedGsubSubtable::Alternate(a) => Some(a.coverage()), - ParsedGsubSubtable::Ligature(l) => Some(l.coverage()), - ParsedGsubSubtable::ChainContext(ChainContextAny::Format3(c3)) => c3.input_first_coverage(), - ParsedGsubSubtable::Context(GsubContext::Format3(c3)) => c3.input().first(), - _ => None, - } -} +/// A GSUB lookup's subtables, parsed on demand. +pub(super) type GsubSubtables<'a> = LazySubtables<'a, ParsedGsubSubtable<'a>>; -/// Reports whether at least one glyph in `run` could trigger any -/// subtable in `parsed`, a fast pre-filter so the cursor walk in -/// `apply_gsub_lookup` skips lookups whose coverage doesn't intersect -/// the run at all. Mirrors HarfBuzz's `would_apply` skip; returns -/// `true` conservatively when a subtable doesn't expose its primary -/// coverage cheaply. -pub(super) fn lookup_might_apply(parsed: &[ParsedGsubSubtable<'_>], run: &[Glyph]) -> bool { - if run.is_empty() { - return false; - } - parsed.iter().any(|sub| match primary_coverage_of(sub) { - None => true, - Some(cov) => run.iter().any(|g| cov.contains(g.glyph_id as u16)), - }) -} - -/// True when every subtable in `parsed` exposes a single primary -/// coverage we can intersect with the run. When that holds, the -/// cursor walker can use the "digest" path: skip any cursor whose -/// glyph isn't in the union of those coverages, instead of trying -/// every subtable at every cursor. -pub(super) fn parsed_has_full_digest(parsed: &[ParsedGsubSubtable<'_>]) -> bool { - parsed.iter().all(|s| primary_coverage_of(s).is_some()) +/// The subtables of `lookup`, none parsed yet. +pub(super) fn lazy_subtables(lookup: Lookup<'_>) -> GsubSubtables<'_> { + LazySubtables::new(lookup, parse_subtable) } -/// True when glyph `id` is in any of `parsed`'s primary coverages. -/// Caller has already established that every subtable exposes one -/// (`parsed_has_full_digest`). -pub(super) fn cursor_in_digest(parsed: &[ParsedGsubSubtable<'_>], id: u16) -> bool { - parsed - .iter() - .filter_map(primary_coverage_of) - .any(|cov| cov.contains(id)) +/// Parses subtable `sub_idx` of `lookup`, looking through an Extension +/// (type 7) wrapper, so the caller never sees raw lookup type 7. +/// `None` for a subtable that fails to parse, which the shaper drops. +fn parse_subtable<'a>(lookup: &Lookup<'a>, sub_idx: u16) -> Option> { + let bytes = lookup.subtable_bytes(sub_idx)?; + let raw_lt = lookup.lookup_type(); + let (effective_lt, inner_bytes) = if raw_lt == gsub_lt::EXTENSION { + resolve_extension(bytes)? + } else { + (raw_lt, bytes) + }; + match effective_lt { + gsub_lt::SINGLE => Single::parse(inner_bytes) + .ok() + .map(ParsedGsubSubtable::Single), + gsub_lt::MULTIPLE => Multiple::parse(inner_bytes) + .ok() + .map(ParsedGsubSubtable::Multiple), + gsub_lt::ALTERNATE => Alternate::parse(inner_bytes) + .ok() + .map(ParsedGsubSubtable::Alternate), + gsub_lt::LIGATURE => Ligature::parse(inner_bytes) + .ok() + .map(ParsedGsubSubtable::Ligature), + gsub_lt::CONTEXT => GsubContext::parse(inner_bytes) + .ok() + .map(ParsedGsubSubtable::Context), + gsub_lt::CHAINED_CONTEXT => ChainContextAny::parse(inner_bytes) + .ok() + .map(ParsedGsubSubtable::ChainContext), + gsub_lt::REVERSE_CHAINED => ReverseChain::parse(inner_bytes) + .ok() + .map(ParsedGsubSubtable::ReverseChained), + _ => None, + } } /// Tries the subtables of one lookup at the cursor in order. The first @@ -150,6 +96,10 @@ pub(super) fn cursor_in_digest(parsed: &[ParsedGsubSubtable<'_>], id: u16) -> bo /// past the glyphs a ligature kept inside its match, and at the end /// of a contextual match. Returns whether one applied. /// +/// A subtable `accel` shows cannot cover the cursor glyph is passed +/// over unparsed: it starts by looking the glyph up in that coverage, +/// so it would not have applied. +/// /// `nested` is set when a contextual lookup dispatched this one: /// reverse chaining substitutions do not apply then, as in HarfBuzz. /// The cursor glyph is not checked against the lookup's flags here; @@ -158,7 +108,8 @@ pub(super) fn cursor_in_digest(parsed: &[ParsedGsubSubtable<'_>], id: u16) -> bo #[allow(clippy::too_many_arguments)] pub(super) fn apply_parsed_lookup_at( cx: &GsubCx<'_>, - parsed: &[ParsedGsubSubtable<'_>], + subtables: &mut GsubSubtables<'_>, + accel: &Accel<'_, '_>, mcx: &MatchContext<'_>, buf: &mut GsubBuffer, depth: u8, @@ -171,8 +122,16 @@ pub(super) fn apply_parsed_lookup_at( }; let id = cur.glyph_id as u16; let at = buf.cursor(); - for (k, sub) in parsed.iter().enumerate() { - let mcx = &mcx.with_rule_set_digests(k < SUBTABLE_CACHES); + for index in 0..subtables.len() { + if !accel.subtable_may_start(subtables.is_parsed(index), usize::from(index), id) { + continue; + } + let Some((sub, digests)) = + subtables.get_with_digests(index, ParsedGsubSubtable::reads_rule_set_digests) + else { + continue; + }; + let mcx = &mcx.with_rule_set_digests(digests); let applied = match sub { ParsedGsubSubtable::Single(single) => { single.apply(id).map(|out| buf.replace_glyph(out)) diff --git a/src/shape/lazy.rs b/src/shape/lazy.rs new file mode 100644 index 00000000..2b261fdd --- /dev/null +++ b/src/shape/lazy.rs @@ -0,0 +1,132 @@ +//! A lookup's subtables, each parsed the first time a glyph reaches +//! it. +//! +//! Parsing a contextual subtable allocates its rule arrays, and a +//! lookup can have hundreds of them (Noto Sans KR's `ccmp` has 268), +//! of which a short run reaches one or two. The lookup accelerators +//! (see [`crate::tables::layout::accel`]) tell which subtables a glyph +//! can reach; this parses only those, once per lookup application. + +use alloc::vec::Vec; + +use crate::tables::layout::skip_iter::SUBTABLE_CACHES; +use crate::tables::layout::Lookup; + +/// Subtables whose slots a [`LazySubtables`] keeps inline, so that a +/// small lookup is applied without allocating them. +const INLINE_SLOTS: usize = 8; + +/// The subtables of one lookup, parsed on demand with `parse`. +pub(super) struct LazySubtables<'a, T> { + lookup: Lookup<'a>, + /// For each of the first [`INLINE_SLOTS`] subtable indices, 0 + /// before it is parsed, else one more than its position in + /// `parsed`. + first: [u16; INLINE_SLOTS], + /// The same for the other subtables. + rest: Vec, + /// The subtables parsed so far, `None` for one that failed to + /// parse (the shaper drops those). + parsed: Vec>, + parse: fn(&Lookup<'a>, u16) -> Option, +} + +impl<'a, T> LazySubtables<'a, T> { + /// The subtables of `lookup`, none parsed yet. + pub(super) fn new(lookup: Lookup<'a>, parse: fn(&Lookup<'a>, u16) -> Option) -> Self { + let count = usize::from(lookup.subtable_count()); + Self { + first: [0; INLINE_SLOTS], + rest: alloc::vec![0; count.saturating_sub(INLINE_SLOTS)], + lookup, + parsed: Vec::new(), + parse, + } + } + + /// The slot of subtable `index`, `None` past the last subtable. + #[inline] + fn slot(&self, index: u16) -> Option { + if index >= self.len() { + return None; + } + let i = usize::from(index); + match i.checked_sub(INLINE_SLOTS) { + None => self.first.get(i).copied(), + Some(i) => self.rest.get(i).copied(), + } + } + + fn set_slot(&mut self, index: u16, slot: u16) { + let i = usize::from(index); + let cell = match i.checked_sub(INLINE_SLOTS) { + None => self.first.get_mut(i), + Some(i) => self.rest.get_mut(i), + }; + if let Some(cell) = cell { + *cell = slot; + } + } + + /// Number of subtables, parsed or not. + pub(super) fn len(&self) -> u16 { + self.lookup.subtable_count() + } + + /// True once subtable `index` has been parsed (whether or not it + /// parsed). + #[inline] + pub(super) fn is_parsed(&self, index: u16) -> bool { + self.slot(index).is_some_and(|s| s != 0) + } + + /// Subtable `index`, parsed now if it was not yet; `None` when it + /// does not parse. + #[inline] + pub(super) fn get(&mut self, index: u16) -> Option<&T> { + let slot = self.slot(index)?; + let at = if slot == 0 { + let parsed = (self.parse)(&self.lookup, index); + self.parsed.push(parsed); + // At most one entry per subtable, so the position fits. + let at = self.parsed.len() - 1; + self.set_slot(index, (at + 1) as u16); + at + } else { + usize::from(slot - 1) + }; + self.parsed.get(at)?.as_ref() + } + + /// Subtable `index`, parsed now if it was not yet, and whether + /// HarfBuzz gives it a cache, as [`Self::has_cache`] tells for a + /// subtable `reads_digests` says reads one; `None` when it does not + /// parse. + #[inline] + pub(super) fn get_with_digests( + &mut self, + index: u16, + reads_digests: fn(&T) -> bool, + ) -> Option<(&T, bool)> { + if usize::from(index) >= SUBTABLE_CACHES + && reads_digests(self.get(index)?) + && !self.has_cache(index) + { + return self.get(index).map(|t| (t, false)); + } + self.get(index).map(|t| (t, true)) + } + + /// Whether subtable `index`, one that parses, is among the first + /// [`SUBTABLE_CACHES`] subtables of the lookup that parse, which + /// HarfBuzz gives a cache (see + /// [`crate::tables::layout::MatchContext::with_rule_set_digests`]). + /// Parses the subtables before it when that takes counting. + pub(super) fn has_cache(&mut self, index: u16) -> bool { + if usize::from(index) < SUBTABLE_CACHES { + return true; + } + let failed = (0..index).filter(|&i| self.get(i).is_none()).count(); + usize::from(index) - failed < SUBTABLE_CACHES + } +} diff --git a/src/shape/pipeline.rs b/src/shape/pipeline.rs index 2f2a2132..3c2438f2 100644 --- a/src/shape/pipeline.rs +++ b/src/shape/pipeline.rs @@ -249,20 +249,31 @@ pub fn shape(font: &Font<'_>, buffer: &Buffer, features: &[Feature]) -> Result, buffer: &Buffer, features: &[Feature]) -> Result, glyphs: /// `script_priority` lists feature `tag`, with or without lookups (see /// [`crate::ot::layout_select::lists_feature`]). fn lists_feature(gpos: &Gpos<'_>, tag: [u8; 4], script_priority: &[[u8; 4]]) -> bool { - crate::ot::layout_select::lists_feature( - gpos.script_list(), - &gpos.features(), - gpos.language_tags(), - tag, - script_priority, - ) + gpos.layout_view().lists(tag, script_priority) } /// Lookup indices feature `tag` selects for one segment's script. fn lookups_for(gpos: &Gpos<'_>, tag: [u8; 4], script_priority: &[[u8; 4]]) -> Vec { - crate::ot::layout_select::feature_lookup_indices( - gpos.script_list(), - &gpos.features(), - gpos.language_tags(), - tag, - script_priority, - ) - .unwrap_or_default() + gpos.layout_view() + .feature_lookups(tag, script_priority) + .unwrap_or_default() } /// Lookups of the required feature of the language system GPOS picks /// for one segment's script, which join the GPOS stage whatever their /// tag (HarfBuzz's `hb_ot_map_builder_t::compile`). fn required_lookups(gpos: &Gpos<'_>, script_priority: &[[u8; 4]]) -> Vec { - crate::ot::layout_select::required_feature( - gpos.script_list(), - &gpos.features(), - gpos.language_tags(), - script_priority, - ) - .map(|(_, lookups)| lookups) - .unwrap_or_default() + gpos.layout_view() + .required(script_priority) + .map(|(_, lookups)| lookups) + .unwrap_or_default() } /// HarfBuzz's `zero_mark_widths_by_gdef`: every mark loses both diff --git a/src/shape/required.rs b/src/shape/required.rs index 7c2f5706..cd5ba7e0 100644 --- a/src/shape/required.rs +++ b/src/shape/required.rs @@ -208,27 +208,17 @@ pub(super) fn apply_stage_zero( if glyphs.is_empty() { return; } - let features = gsub.features(); + let view = gsub.layout_view(); // A required `rvrn` runs here too, in the stage of its tag. - let required = crate::ot::layout_select::required_feature( - gsub.script_list(), - &features, - gsub.language_tags(), - script_priority, - ) - .filter(|&(tag, _)| tag == RVRN || !plan.applies(tag)) - .map(|(_, lookups)| lookups) - .unwrap_or_default(); + let required = view + .required(script_priority) + .filter(|&(tag, _)| tag == RVRN || !plan.applies(tag)) + .map(|(_, lookups)| lookups) + .unwrap_or_default(); let rvrn = if feature_disabled(plan.features, RVRN) { Vec::new() } else { - crate::ot::layout_select::listed_feature_lookups( - gsub.script_list(), - &features, - gsub.language_tags(), - RVRN, - script_priority, - ) + view.listed_lookups(RVRN, script_priority) }; if required.is_empty() && rvrn.is_empty() { return; diff --git a/src/shape/syllabic.rs b/src/shape/syllabic.rs index f506803c..4c5c1c3b 100644 --- a/src/shape/syllabic.rs +++ b/src/shape/syllabic.rs @@ -15,7 +15,7 @@ use alloc::vec::Vec; -use super::gsub::{apply_gsub_lookup, apply_gsub_lookups_masked}; +use super::gsub::{apply_gsub_lookup, apply_gsub_lookups_masked, gsub_lookup_may_apply}; use super::joiners::FeatureFlags; use super::LookupBudget; use crate::buffer::Glyph; @@ -66,6 +66,13 @@ impl<'a> SyllabicGsub<'a> { ); } + /// True when lookup `index` may change `glyphs`: false when none of + /// them can start one of its subtables, so applying it would leave + /// the run as it is. + pub(crate) fn may_apply(&self, index: u16, glyphs: &[Glyph]) -> bool { + gsub_lookup_may_apply(self.gsub, index, glyphs) + } + /// Applies lookup `index` to `glyphs`, with the feature's joiner /// handling. The lookup applies at a glyph only when `applies` /// accepts it. With `per_syllable`, it matches only glyphs whose @@ -83,6 +90,9 @@ impl<'a> SyllabicGsub<'a> { if glyphs.is_empty() { return Vec::new(); } + if !self.may_apply(index, glyphs) { + return alloc::vec![false; glyphs.len()]; + } let mask: Vec = glyphs.iter().map(applies).collect(); let flags = FeatureFlags { joiners, diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 00000000..a5f2fc3c --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,181 @@ +//! A write-once box that threads can share without a lock, for the +//! caches a [`crate::Font`] builds the first time it needs them. +//! +//! The core crate has no `std`, so `std::sync::OnceLock` is out of +//! reach. [`OnceBox`] is the lock-free alternative: the first thread to +//! need the value builds it and publishes it with one compare-and-swap. +//! Threads that race it may build the value too, and drop theirs when +//! they lose; every cache built here is a pure function of the font, so +//! whichever copy wins holds the same data and shaping stays +//! deterministic. + +use alloc::boxed::Box; +use core::fmt; +use core::marker::PhantomData; +use core::ptr; +use core::sync::atomic::{AtomicPtr, Ordering}; + +/// A value built at most once and then shared, behind an atomic +/// pointer: null until the value is set, then a pointer from +/// [`Box::into_raw`] that only [`Drop`] frees. +pub(crate) struct OnceBox { + ptr: AtomicPtr, + /// The box is owned, so `OnceBox` drops a `T`. + _owns: PhantomData>, +} + +impl OnceBox { + /// An empty box. + pub(crate) const fn new() -> Self { + Self { + ptr: AtomicPtr::new(ptr::null_mut()), + _owns: PhantomData, + } + } + + /// A box that already holds `value`. + pub(crate) fn with_value(value: T) -> Self { + Self { + ptr: AtomicPtr::new(Box::into_raw(Box::new(value))), + _owns: PhantomData, + } + } + + /// The value, if it has been set. + pub(crate) fn get(&self) -> Option<&T> { + let p = self.ptr.load(Ordering::Acquire); + // SAFETY: a non-null pointer came from `Box::into_raw` in + // `with_value` or `get_or_init` and was published with a + // release store or swap that the acquire load above pairs with, + // so the value is fully written. It is only freed in `drop`, + // which takes `&mut self`, so it outlives this `&self` borrow. + unsafe { p.as_ref() } + } + + /// The value, built with `init` if it has not been set. When two + /// threads race, both may run `init`; the first to publish wins and + /// the other's value is dropped. + pub(crate) fn get_or_init(&self, init: impl FnOnce() -> T) -> &T { + if let Some(value) = self.get() { + return value; + } + let new = Box::into_raw(Box::new(init())); + match self + .ptr + .compare_exchange(ptr::null_mut(), new, Ordering::AcqRel, Ordering::Acquire) + { + // SAFETY: `new` came from `Box::into_raw` just above and is + // now owned by `self`, which frees it only in `drop`. + Ok(_) => unsafe { &*new }, + Err(winner) => { + // SAFETY: the swap failed, so `new` was never published + // and this thread still owns it; `Box::from_raw` takes + // it back to drop it. + drop(unsafe { Box::from_raw(new) }); + // SAFETY: `winner` is the non-null pointer another + // thread published (the swap only fails when the slot + // is no longer null), acquired by the failure ordering, + // and freed only in `drop`. + unsafe { &*winner } + } + } + } +} + +impl Drop for OnceBox { + fn drop(&mut self) { + let p = *self.ptr.get_mut(); + if !p.is_null() { + // SAFETY: a non-null pointer came from `Box::into_raw` and + // `&mut self` means no borrow handed out by `get` is alive, + // so this is the last use of it. + drop(unsafe { Box::from_raw(p) }); + } + } +} + +impl Default for OnceBox { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for OnceBox { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("OnceBox").field(&self.get()).finish() + } +} + +// SAFETY: `OnceBox` owns a `T` like `Box`, so sending it to +// another thread sends the `T`. +unsafe impl Send for OnceBox {} +// SAFETY: a shared `OnceBox` hands out `&T` to every thread (needs +// `T: Sync`), and a thread that loses an initialization race drops the +// `T` it built while the winner's is in use elsewhere, and the box +// drops whichever `T` it holds on the thread that drops it (needs +// `T: Send`). +unsafe impl Sync for OnceBox {} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::sync::Arc; + use alloc::vec::Vec; + use core::sync::atomic::AtomicUsize; + + #[test] + fn builds_once_and_keeps_the_value() { + let cell: OnceBox> = OnceBox::new(); + assert!(cell.get().is_none()); + assert_eq!(cell.get_or_init(|| alloc::vec![1, 2, 3]), &[1, 2, 3]); + assert_eq!(cell.get_or_init(|| alloc::vec![9]), &[1, 2, 3]); + assert_eq!(cell.get(), Some(&alloc::vec![1, 2, 3])); + let set = OnceBox::with_value(7u8); + assert_eq!(set.get_or_init(|| 8), &7); + } + + #[test] + fn drops_the_value_once() { + struct Counted(Arc); + impl Drop for Counted { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let drops = Arc::new(AtomicUsize::new(0)); + { + let cell = OnceBox::new(); + cell.get_or_init(|| Counted(drops.clone())); + cell.get_or_init(|| Counted(drops.clone())); + assert_eq!(drops.load(Ordering::SeqCst), 0); + } + assert_eq!(drops.load(Ordering::SeqCst), 1); + drop(OnceBox::::new()); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[cfg(feature = "std")] + #[test] + fn racing_threads_agree_on_one_value() { + for _ in 0..50 { + let cell: Arc>> = Arc::new(OnceBox::new()); + let builds = Arc::new(AtomicUsize::new(0)); + let handles: Vec<_> = (0..8) + .map(|_| { + let (cell, builds) = (cell.clone(), builds.clone()); + std::thread::spawn(move || { + let v = cell.get_or_init(|| { + builds.fetch_add(1, Ordering::SeqCst); + (0..64).collect() + }); + (v.as_ptr() as usize, v.len()) + }) + }) + .collect(); + let seen: Vec<(usize, usize)> = + handles.into_iter().map(|h| h.join().unwrap()).collect(); + assert!(seen.iter().all(|s| *s == seen[0]), "{seen:?}"); + assert!(builds.load(Ordering::SeqCst) >= 1); + } + } +} diff --git a/src/tables/gpos/mod.rs b/src/tables/gpos/mod.rs index f24838aa..5d55a990 100644 --- a/src/tables/gpos/mod.rs +++ b/src/tables/gpos/mod.rs @@ -14,8 +14,10 @@ //! subtables are parsed. use crate::error::{Error, Result}; +use crate::ot::layout_select::LayoutView; +use crate::tables::layout::accel::{accel_for, Accel, LayoutCache}; use crate::tables::layout::{ - ActiveFeatures, FeatureList, FeatureVariations, LookupList, ScriptList, + ActiveFeatures, FeatureList, FeatureVariations, LayoutTable, Lookup, LookupList, ScriptList, }; use crate::tables::parse::Reader; @@ -86,6 +88,12 @@ pub struct Gpos<'a> { /// resolves a feature through this view. Empty selects each /// script's default language system. language_tags: &'a [[u8; 4]], + /// What the font keeps for this table between shaping calls: the + /// lookup accelerators, which let the shaper pass over lookups and + /// subtables that cannot apply without parsing them, and the + /// resolved language systems. `None` reads each lookup's coverages + /// directly and walks the language system per query. + cache: Option<&'a LayoutCache>, } impl<'a> Gpos<'a> { @@ -145,6 +153,7 @@ impl<'a> Gpos<'a> { feature_variations_offset, feature_variation: None, language_tags: &[], + cache: None, }) } @@ -203,6 +212,33 @@ impl<'a> Gpos<'a> { self } + /// Returns this view with what the font keeps for the table: the + /// lookup accelerators [`Self::lookup_accel`] hands out and the + /// language systems [`Self::layout_view`] resolves, each built the + /// first time it is needed. + #[must_use] + pub(crate) const fn with_cache(mut self, cache: Option<&'a LayoutCache>) -> Self { + self.cache = cache; + self + } + + /// What feature resolution (`crate::ot::layout_select`) reads of + /// this view. + pub(crate) fn layout_view(&self) -> LayoutView<'a> { + LayoutView { + script_list: self.script_list, + features: self.features(), + language_tags: self.language_tags, + maps: self.cache.map(|c| &c.maps), + } + } + + /// The accelerator of `lookup`, lookup `index` of the table: the + /// font's, or one built for this use when the view has none. + pub(crate) fn lookup_accel<'l>(&self, index: u16, lookup: &Lookup<'l>) -> Accel<'a, 'l> { + accel_for(self.cache, LayoutTable::Gpos, index, lookup) + } + /// The FeatureList as the shaper sees it: with the substitutions of /// the record [`Self::with_feature_variation`] selected, if any. pub(crate) const fn features(&self) -> ActiveFeatures<'a> { diff --git a/src/tables/gsub/mod.rs b/src/tables/gsub/mod.rs index fe13de88..202cdebb 100644 --- a/src/tables/gsub/mod.rs +++ b/src/tables/gsub/mod.rs @@ -13,8 +13,10 @@ use crate::buffer::ClusterLevel; use crate::error::{Error, Result}; +use crate::ot::layout_select::LayoutView; +use crate::tables::layout::accel::{accel_for, Accel, LayoutCache}; use crate::tables::layout::{ - ActiveFeatures, FeatureList, FeatureVariations, LookupList, ScriptList, + ActiveFeatures, FeatureList, FeatureVariations, LayoutTable, Lookup, LookupList, ScriptList, }; use crate::tables::parse::Reader; @@ -79,6 +81,12 @@ pub struct Gsub<'a> { /// resolves a feature through this view. Empty selects each /// script's default language system. language_tags: &'a [[u8; 4]], + /// What the font keeps for this table between shaping calls: the + /// lookup accelerators, which let the shaper pass over lookups and + /// subtables that cannot apply without parsing them, and the + /// resolved language systems. `None` reads each lookup's coverages + /// directly and walks the language system per query. + cache: Option<&'a LayoutCache>, /// Cluster level of the run being shaped: a ligature merges its /// components' clusters only at the monotone levels, as /// HarfBuzz's `ligate_input` does through its buffer. @@ -145,6 +153,7 @@ impl<'a> Gsub<'a> { feature_variations_offset, feature_variation: None, language_tags: &[], + cache: None, cluster_level: ClusterLevel::MonotoneCharacters, unsafe_to_concat: false, }) @@ -209,6 +218,33 @@ impl<'a> Gsub<'a> { self } + /// Returns this view with what the font keeps for the table: the + /// lookup accelerators [`Self::lookup_accel`] hands out and the + /// language systems [`Self::layout_view`] resolves, each built the + /// first time it is needed. + #[must_use] + pub(crate) const fn with_cache(mut self, cache: Option<&'a LayoutCache>) -> Self { + self.cache = cache; + self + } + + /// What feature resolution (`crate::ot::layout_select`) reads of + /// this view. + pub(crate) fn layout_view(&self) -> LayoutView<'a> { + LayoutView { + script_list: self.script_list, + features: self.features(), + language_tags: self.language_tags, + maps: self.cache.map(|c| &c.maps), + } + } + + /// The accelerator of `lookup`, lookup `index` of the table: the + /// font's, or one built for this use when the view has none. + pub(crate) fn lookup_accel<'l>(&self, index: u16, lookup: &Lookup<'l>) -> Accel<'a, 'l> { + accel_for(self.cache, LayoutTable::Gsub, index, lookup) + } + /// The FeatureList as the shaper sees it: with the substitutions of /// the record [`Self::with_feature_variation`] selected, if any. pub(crate) const fn features(&self) -> ActiveFeatures<'a> { diff --git a/src/tables/layout/accel.rs b/src/tables/layout/accel.rs new file mode 100644 index 00000000..1b6c7da6 --- /dev/null +++ b/src/tables/layout/accel.rs @@ -0,0 +1,630 @@ +//! Lookup accelerators: for each lookup, a digest of the glyphs its +//! subtables can start at, so a lookup that cannot apply to a run is +//! passed over without parsing a subtable, as HarfBuzz's +//! `hb_ot_layout_lookup_accelerator_t` does. +//! +//! Every GSUB and GPOS subtable starts by looking the glyph at the +//! cursor up in one Coverage table, its *primary* coverage (the first +//! input coverage of a format 3 context), and does nothing at all when +//! the glyph is not there. A [`Digest`] of that coverage, a small +//! filter with no false negatives, lets the shaper skip the subtable +//! for a glyph it cannot cover, and the union of a lookup's subtable +//! digests lets it skip the whole lookup for a run none of whose glyphs +//! it can cover. Skipping only drops work that would have changed +//! nothing, so the shaped output is the same. +//! +//! A [`crate::Font`] keeps one [`LookupAccels`] per table from its +//! second shaping call on, and each lookup builds its digests the first +//! time it is applied then. In a font's first call, and for a table +//! view without the font's cache, a lookup reads its subtables' primary +//! coverages directly instead ([`DirectAccel`]): a font shaped once, +//! which many callers build per run, would spend more building digests +//! than they save. Building reads each subtable's primary coverage once, with a +//! work budget per lookup: past it, a subtable gets a digest that admits +//! every glyph, so a hostile font that points thousands of subtables at +//! one huge coverage costs no more than the subtables themselves. + +use alloc::boxed::Box; +use alloc::vec::Vec; + +use super::coverage::Coverage; +use super::lookup_list::Lookup; +use super::skip_iter::LayoutTable; +use crate::ot::layout_select::FeatureMaps; +use crate::sync::OnceBox; + +/// Glyph-id bits each of the digest's three masks hashes on. +const SHIFTS: [u32; 3] = [0, 4, 9]; + +/// A set of glyph ids as three 64-bit masks, HarfBuzz's +/// `hb_set_digest_t`: mask `k` has bit `(g >> SHIFTS[k]) & 63` for every +/// glyph `g` added. [`Digest::may_have`] has false positives but no +/// false negatives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Digest { + masks: [u64; 3], +} + +impl Digest { + /// The digest of no glyph. + pub(crate) const EMPTY: Self = Self { masks: [0; 3] }; + /// A digest that admits every glyph. + pub(crate) const FULL: Self = Self { + masks: [u64::MAX; 3], + }; + + fn bit(glyph: u16, shift: u32) -> u64 { + 1 << ((u32::from(glyph) >> shift) & 63) + } + + /// Adds one glyph. + #[cfg(test)] + pub(crate) fn add(&mut self, glyph: u16) { + for (mask, shift) in self.masks.iter_mut().zip(SHIFTS) { + *mask |= Self::bit(glyph, shift); + } + } + + /// Adds every glyph from `first` to `last`, both included. A range + /// with `first > last` adds nothing. + pub(crate) fn add_range(&mut self, first: u16, last: u16) { + if first > last { + return; + } + for (mask, shift) in self.masks.iter_mut().zip(SHIFTS) { + if (u32::from(last) >> shift) - (u32::from(first) >> shift) >= 63 { + *mask = u64::MAX; + } else { + // The bits from `first`'s to `last`'s, wrapping past + // bit 63 (HarfBuzz's `add_range`). + let (a, b) = (Self::bit(first, shift), Self::bit(last, shift)); + *mask |= b + .wrapping_add(b.wrapping_sub(a)) + .wrapping_sub(u64::from(b < a)); + } + } + } + + /// Adds every glyph of `other`. + pub(crate) fn union(&mut self, other: &Self) { + for (mask, o) in self.masks.iter_mut().zip(other.masks) { + *mask |= o; + } + } + + /// False only when `glyph` was never added. + #[inline] + pub(crate) fn may_have(&self, glyph: u16) -> bool { + self.masks + .iter() + .zip(SHIFTS) + .all(|(mask, shift)| mask & Self::bit(glyph, shift) != 0) + } + + /// False only when no glyph was added to both digests. + #[cfg(test)] + pub(crate) fn may_intersect(&self, other: &Self) -> bool { + self.masks + .iter() + .zip(other.masks) + .all(|(mask, o)| mask & o != 0) + } + + /// The digest of `glyphs`. + #[cfg(test)] + pub(crate) fn of(glyphs: impl IntoIterator) -> Self { + let mut d = Self::EMPTY; + for g in glyphs { + d.add(g); + } + d + } +} + +/// Coverage entries one lookup's accelerator may read, at least. +/// Real fonts stay below it: a `vert` substitution of a CJK font covers +/// a few thousand glyphs. +const MIN_COVERAGE_BUDGET: usize = 1 << 14; +/// Coverage entries per subtable the budget grows by. +const COVERAGE_BUDGET_PER_SUBTABLE: usize = 64; + +/// One lookup's accelerator: the digest of every glyph a subtable of +/// it can start at, and each subtable's own digest, by subtable index. +#[derive(Debug)] +pub(crate) struct LookupAccel { + digest: Digest, + subtables: Box<[Digest]>, +} + +impl LookupAccel { + /// Builds the accelerator of `lookup`, a lookup of `table`. + pub(crate) fn build(table: LayoutTable, lookup: &Lookup<'_>) -> Self { + let count = usize::from(lookup.subtable_count()); + let mut budget = + MIN_COVERAGE_BUDGET.max(count.saturating_mul(COVERAGE_BUDGET_PER_SUBTABLE)); + let mut digest = Digest::EMPTY; + let subtables: Vec = (0..lookup.subtable_count()) + .map(|i| { + let d = lookup + .subtable_bytes(i) + .and_then(|bytes| primary_coverage(table, lookup.lookup_type(), bytes)) + .and_then(|cov| coverage_digest(&cov, &mut budget)) + .unwrap_or(Digest::FULL); + digest.union(&d); + d + }) + .collect(); + Self { + digest, + subtables: subtables.into_boxed_slice(), + } + } + + /// The digest of every glyph a subtable of the lookup can start at. + #[cfg(test)] + pub(crate) fn digest(&self) -> &Digest { + &self.digest + } + + /// True when subtable `index` may apply at a glyph `glyph`: false + /// only when the glyph is not in the subtable's primary coverage. + #[inline] + pub(crate) fn subtable_may_have(&self, index: usize, glyph: u16) -> bool { + self.subtables + .get(index) + .map_or(true, |d| d.may_have(glyph)) + } +} + +/// A lookup's subtables' primary coverages, read for one application +/// of the lookup without building digests. `None` admits every glyph. +#[derive(Debug)] +pub(crate) struct DirectAccel<'a> { + /// The coverages of the first [`INLINE_COVERAGES`] subtables, kept + /// inline so that a small lookup reads its coverages without + /// allocating. + first: [Option>; INLINE_COVERAGES], + /// The coverages of the other subtables. + rest: Vec>>, + count: usize, +} + +/// Subtables whose coverages a [`DirectAccel`] keeps inline. +const INLINE_COVERAGES: usize = 4; + +impl<'a> DirectAccel<'a> { + fn new(table: LayoutTable, lookup: &Lookup<'a>) -> Self { + let coverage = |i: u16| { + let bytes = lookup.subtable_bytes(i)?; + primary_coverage(table, lookup.lookup_type(), bytes) + }; + let count = usize::from(lookup.subtable_count()); + let mut first = [None; INLINE_COVERAGES]; + for (i, slot) in (0..lookup.subtable_count()).zip(first.iter_mut()) { + *slot = coverage(i); + } + let rest = (0..lookup.subtable_count()) + .skip(INLINE_COVERAGES) + .map(coverage) + .collect(); + Self { first, rest, count } + } + + /// Subtable `index`'s coverage: `Some(None)` for a subtable without + /// one sigilbuzz reads, `None` past the last subtable. + #[inline] + fn coverage(&self, index: usize) -> Option<&Option>> { + if index >= self.count { + return None; + } + match index.checked_sub(INLINE_COVERAGES) { + None => self.first.get(index), + Some(i) => self.rest.get(i), + } + } + + #[inline] + fn covers(&self, index: usize, glyph: u16) -> bool { + self.coverage(index) + .map_or(true, |c| c.as_ref().map_or(true, |c| c.contains(glyph))) + } + + fn covers_any(&self, glyph: u16) -> bool { + (0..self.count).any(|i| self.covers(i, glyph)) + } +} + +/// A lookup's accelerator for one application: its digests when the +/// font has built them, else its subtables' coverages read directly. +/// Either way, a subtable or lookup it rules out for a glyph would not +/// have applied there. +#[derive(Debug)] +pub(crate) enum Accel<'c, 'a> { + /// The digests the font keeps. + Built(&'c LookupAccel), + /// The coverages, read for this application. + Direct(DirectAccel<'a>), +} + +impl Accel<'_, '_> { + /// False only when no glyph of `glyphs` can start a subtable. + pub(crate) fn may_apply(&self, glyphs: impl IntoIterator) -> bool { + match self { + Self::Built(a) => glyphs.into_iter().any(|g| a.digest.may_have(g)), + Self::Direct(d) => glyphs.into_iter().any(|g| d.covers_any(g)), + } + } + + /// False only when no subtable can start at `glyph`. Without + /// digests this searches the coverages until one holds the glyph, + /// which lets a walk pass over a glyph before it reads the glyph's + /// properties. + #[inline] + pub(crate) fn may_have(&self, glyph: u16) -> bool { + match self { + Self::Built(a) => a.digest.may_have(glyph), + Self::Direct(d) => d.covers_any(glyph), + } + } + + /// [`Self::may_have`] where it costs no more than bit tests: without + /// digests every glyph is admitted, and the subtables' own coverage + /// checks decide. + #[inline] + pub(crate) fn may_have_cheaply(&self, glyph: u16) -> bool { + match self { + Self::Built(a) => a.digest.may_have(glyph), + Self::Direct(_) => true, + } + } + + /// False only when subtable `index` cannot start at `glyph`. + #[cfg(test)] + pub(crate) fn subtable_may_have(&self, index: usize, glyph: u16) -> bool { + self.subtable_may_start(false, index, glyph) + } + + /// False only when subtable `index` cannot start at `glyph`, as a + /// check ahead of applying it. Without digests, a subtable that is + /// already `parsed` is let through: it looks the glyph up in its + /// coverage first thing, so reading the coverage here too would + /// only search it twice. One not parsed yet is checked, which keeps + /// it from being parsed for a glyph it cannot cover. + #[inline] + pub(crate) fn subtable_may_start(&self, parsed: bool, index: usize, glyph: u16) -> bool { + match self { + Self::Built(a) => a.subtable_may_have(index, glyph), + Self::Direct(d) => parsed || d.covers(index, glyph), + } + } +} + +/// The accelerators of every lookup of one GSUB or GPOS table, each +/// built the first time it is asked for and then kept. +pub(crate) struct LookupAccels { + table: LayoutTable, + lookups: Box<[OnceBox]>, +} + +impl LookupAccels { + /// Room for the `lookup_count` lookups of a `table` table, none + /// built yet. + pub(crate) fn new(table: LayoutTable, lookup_count: u16) -> Self { + Self { + table, + lookups: (0..lookup_count).map(|_| OnceBox::new()).collect(), + } + } + + /// The accelerator of `lookup`, lookup `index` of the table, built + /// now if this is the first time it is asked for. Past the table's + /// lookups, its coverages are read for this one use. + pub(crate) fn get<'a>(&self, index: u16, lookup: &Lookup<'a>) -> Accel<'_, 'a> { + match self.lookups.get(usize::from(index)) { + Some(slot) => Accel::Built(slot.get_or_init(|| LookupAccel::build(self.table, lookup))), + None => Accel::Direct(DirectAccel::new(self.table, lookup)), + } + } + + /// How many lookup accelerators have been built. + #[cfg(test)] + pub(crate) fn built(&self) -> usize { + self.lookups.iter().filter(|s| s.get().is_some()).count() + } + + /// Heap bytes the accelerators hold. + pub(crate) fn heap_bytes(&self) -> usize { + let slots = self.lookups.len() * core::mem::size_of::>(); + let built: usize = self + .lookups + .iter() + .filter_map(OnceBox::get) + .map(|a| { + core::mem::size_of::() + + a.subtables.len() * core::mem::size_of::() + }) + .sum(); + slots + built + } +} + +impl core::fmt::Debug for LookupAccels { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("LookupAccels") + .field("table", &self.table) + .field("lookups", &self.lookups.len()) + .finish_non_exhaustive() + } +} + +/// What a [`crate::Font`] keeps for one GSUB or GPOS table: the lookup +/// accelerators and the resolved language systems. +#[derive(Debug)] +pub(crate) struct LayoutCache { + pub(crate) accels: LookupAccels, + pub(crate) maps: FeatureMaps, +} + +impl LayoutCache { + /// An empty cache for a `table` table of `lookup_count` lookups. + pub(crate) fn new(table: LayoutTable, lookup_count: u16) -> Self { + Self { + accels: LookupAccels::new(table, lookup_count), + maps: FeatureMaps::new(), + } + } + + /// Heap bytes the cache holds. + pub(crate) fn heap_bytes(&self) -> usize { + self.accels.heap_bytes() + self.maps.heap_bytes() + } +} + +/// The accelerator of `lookup`: from `cache` when the view has one, +/// built now if need be, else its coverages read for this one use. +pub(crate) fn accel_for<'c, 'a>( + cache: Option<&'c LayoutCache>, + table: LayoutTable, + index: u16, + lookup: &Lookup<'a>, +) -> Accel<'c, 'a> { + match cache { + Some(cache) => cache.accels.get(index, lookup), + None => Accel::Direct(DirectAccel::new(table, lookup)), + } +} + +/// The digest of `cov`'s glyphs, or `None` when reading them all would +/// overrun `budget` (the caller then admits every glyph). Spends the +/// entries read. +fn coverage_digest(cov: &Coverage<'_>, budget: &mut usize) -> Option { + let entries = usize::from(cov.len()); + *budget = budget.checked_sub(entries)?; + let mut d = Digest::EMPTY; + cov.for_each_range(|first, last| d.add_range(first, last)); + Some(d) +} + +/// The coverage the subtable at `bytes`, of lookup type `lookup_type` +/// in `table`, looks the cursor glyph up in before anything else, or +/// `None` when it cannot be read or the subtable has none sigilbuzz +/// reads that way (the caller then admits every glyph). Extension +/// subtables are looked through. +fn primary_coverage(table: LayoutTable, lookup_type: u16, bytes: &[u8]) -> Option> { + let read = |at: usize| -> Option { + Some(u16::from_be_bytes([*bytes.get(at)?, *bytes.get(at + 1)?])) + }; + let format = read(0)?; + let (extension, context, chain) = match table { + LayoutTable::Gsub => (7, 5, 6), + LayoutTable::Gpos => (9, 7, 8), + }; + let offset = match (table, lookup_type) { + (_, t) if t == extension => { + if format != 1 { + return None; + } + let inner = read(2)?; + if inner == extension { + return None; + } + let off = u32::from_be_bytes([ + *bytes.get(4)?, + *bytes.get(5)?, + *bytes.get(6)?, + *bytes.get(7)?, + ]); + let inner_bytes = bytes.get(usize::try_from(off).ok()?..)?; + return primary_coverage(table, inner, inner_bytes); + } + (_, t) if t == context => match format { + 1 | 2 => read(2)?, + // ContextFormat3: glyphCount, seqLookupCount, then the + // input coverages. + 3 if read(2)? > 0 => read(6)?, + _ => return None, + }, + (_, t) if t == chain => match format { + 1 | 2 => read(2)?, + // ChainContextFormat3: the backtrack coverages, then the + // input ones. + 3 => { + let backtrack = usize::from(read(2)?); + let input_at = 4 + 2 * backtrack; + if read(input_at)? == 0 { + return None; + } + read(input_at + 2)? + } + _ => return None, + }, + // Single (formats 1 and 2), Multiple, Alternate, Ligature and + // ReverseChainSingle substitution. + (LayoutTable::Gsub, 1) if matches!(format, 1 | 2) => read(2)?, + (LayoutTable::Gsub, 2 | 3 | 4 | 8) if format == 1 => read(2)?, + // Single and pair adjustment (formats 1 and 2), cursive + // attachment, and the mark attachments' mark coverage. + (LayoutTable::Gpos, 1 | 2) if matches!(format, 1 | 2) => read(2)?, + (LayoutTable::Gpos, 3..=6) if format == 1 => read(2)?, + _ => return None, + }; + Coverage::parse(bytes.get(usize::from(offset)..)?).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tables::layout::LookupList; + use alloc::vec; + + #[test] + fn digests_have_no_false_negatives() { + let mut d = Digest::EMPTY; + assert!(!d.may_have(5)); + for g in [0, 5, 63, 64, 1000, 4095, 65_535] { + d.add(g); + } + for g in [0, 5, 63, 64, 1000, 4095, 65_535] { + assert!(d.may_have(g), "{g}"); + } + let mut r = Digest::EMPTY; + r.add_range(60, 70); + for g in 60..=70 { + assert!(r.may_have(g), "{g}"); + } + // Wrapping past bit 63 of every mask. + let mut r = Digest::EMPTY; + r.add_range(1000, 1100); + for g in 1000..=1100 { + assert!(r.may_have(g), "{g}"); + } + let mut w = Digest::EMPTY; + w.add_range(0, 65_535); + assert_eq!(w, Digest::FULL); + let mut e = Digest::EMPTY; + e.add_range(9, 3); + assert_eq!(e, Digest::EMPTY); + assert!(d.may_intersect(&Digest::of([5]))); + assert!(!Digest::of([1]).may_intersect(&Digest::of([2]))); + assert!(!Digest::EMPTY.may_intersect(&Digest::FULL)); + } + + #[test] + fn exhaustive_ranges_match_the_glyphs_they_cover() { + for (first, last) in [(0u16, 0u16), (3, 130), (500, 520), (63, 64), (4000, 9000)] { + let mut r = Digest::EMPTY; + r.add_range(first, last); + let mut each = Digest::EMPTY; + for g in first..=last { + each.add(g); + } + // The range sets exactly the bits its glyphs set. + assert_eq!(r, each, "{first}..={last}"); + } + } + + fn push16(out: &mut Vec, v: u16) { + out.extend_from_slice(&v.to_be_bytes()); + } + + /// A LookupList of one lookup of `lookup_type` whose subtables are + /// `subtables`. + fn lookup_list(lookup_type: u16, subtables: &[Vec]) -> Vec { + let mut out = vec![]; + push16(&mut out, 1); + push16(&mut out, 4); + push16(&mut out, lookup_type); + push16(&mut out, 0); + push16(&mut out, subtables.len() as u16); + let mut at = 6 + 2 * subtables.len(); + for s in subtables { + push16(&mut out, at as u16); + at += s.len(); + } + for s in subtables { + out.extend_from_slice(s); + } + out + } + + /// A SingleSubst format 1 over `glyphs` (coverage format 1). + fn single(glyphs: &[u16]) -> Vec { + let mut out = vec![]; + push16(&mut out, 1); + push16(&mut out, 6); + push16(&mut out, 1); + push16(&mut out, 1); + push16(&mut out, glyphs.len() as u16); + for &g in glyphs { + push16(&mut out, g); + } + out + } + + #[test] + fn subtable_digests_follow_their_coverage() { + let data = lookup_list(1, &[single(&[10, 20]), single(&[700]), vec![0, 9]]); + let list = LookupList::parse(&data).unwrap(); + let lookup = list.get(0).unwrap(); + let accel = LookupAccel::build(LayoutTable::Gsub, &lookup); + assert!(accel.subtable_may_have(0, 10)); + assert!(accel.subtable_may_have(0, 20)); + assert!(!accel.subtable_may_have(0, 700)); + assert!(accel.subtable_may_have(1, 700)); + assert!(!accel.subtable_may_have(1, 10)); + // An unknown format admits every glyph. + assert!(accel.subtable_may_have(2, 12_345)); + assert!(accel.subtable_may_have(3, 12_345)); + assert!(accel.digest().may_have(700)); + assert_eq!(*accel.digest(), Digest::FULL); + let data = lookup_list(1, &[single(&[10, 20]), single(&[700])]); + let list = LookupList::parse(&data).unwrap(); + let accel = LookupAccel::build(LayoutTable::Gsub, &list.get(0).unwrap()); + assert!(!accel.digest().may_have(11)); + assert!(!accel.digest().may_have(5000)); + } + + #[test] + fn a_shared_huge_coverage_admits_everything_past_the_budget() { + // Many subtables share one big coverage: past the budget the + // rest admit every glyph instead of reading it again. + let glyphs: Vec = (0..4000).map(|g| g * 2).collect(); + let sub = single(&glyphs); + let subs: Vec> = (0..10).map(|_| sub.clone()).collect(); + let data = lookup_list(1, &subs); + let list = LookupList::parse(&data).unwrap(); + let accel = LookupAccel::build(LayoutTable::Gsub, &list.get(0).unwrap()); + assert!(!accel.subtable_may_have(0, 65_001)); + assert!(accel.subtable_may_have(9, 65_001)); + } + + #[test] + fn accelerators_are_built_once_and_agree_with_the_coverages() { + let data = lookup_list(1, &[single(&[10]), single(&[3000])]); + let list = LookupList::parse(&data).unwrap(); + let lookup = list.get(0).unwrap(); + let accels = LookupAccels::new(LayoutTable::Gsub, 3); + assert_eq!(accels.built(), 0); + let first = accel_for(None, LayoutTable::Gsub, 0, &lookup); + assert!(matches!(first, Accel::Direct(_))); + let second = accels.get(0, &lookup); + assert!(matches!(second, Accel::Built(_))); + assert!(matches!(accels.get(0, &lookup), Accel::Built(_))); + assert_eq!(accels.built(), 1); + // Both answer alike. + for a in [&first, &second] { + assert!(a.may_apply([1, 10])); + assert!(!a.may_apply([1, 11])); + assert!(a.subtable_may_have(0, 10)); + assert!(!a.subtable_may_have(0, 3000)); + assert!(a.subtable_may_have(1, 3000)); + assert!(!a.subtable_may_have(1, 10)); + assert!(a.subtable_may_have(5, 10)); + } + assert!(!second.may_have(11)); + // Past the slots, the coverages are read for one use. + assert!(matches!(accels.get(7, &lookup), Accel::Direct(_))); + assert_eq!(accels.built(), 1); + assert!(accels.heap_bytes() > 0); + } +} diff --git a/src/tables/layout/coverage.rs b/src/tables/layout/coverage.rs index e673421f..7cc06c91 100644 --- a/src/tables/layout/coverage.rs +++ b/src/tables/layout/coverage.rs @@ -127,6 +127,24 @@ impl<'a> Coverage<'a> { self.index_of(glyph_id).is_some() } + /// Calls `f(first, last)` for every glyph array entry (as a range + /// of one) or range record, in table order. Every glyph + /// [`Self::index_of`] can find lies in one of them, sorted or not. + pub(crate) fn for_each_range(&self, mut f: impl FnMut(u16, u16)) { + for i in 0..self.count { + match self.format { + Format::Format1 => { + let g = self.glyph_at(i); + f(g, g); + } + Format::Format2 => { + let (start, end, _) = self.range_at(i); + f(start, end); + } + } + } + } + fn glyph_at(&self, i: u16) -> u16 { let off = self.body_off + i as usize * 2; u16::from_be_bytes([self.data[off], self.data[off + 1]]) diff --git a/src/tables/layout/feature_variations.rs b/src/tables/layout/feature_variations.rs index c25588c1..72b3263c 100644 --- a/src/tables/layout/feature_variations.rs +++ b/src/tables/layout/feature_variations.rs @@ -963,6 +963,11 @@ impl<'a> ActiveFeatures<'a> { self.list.get(index) } + /// The FeatureVariations record selected, if any. + pub(crate) fn record(&self) -> Option { + self.variation.map(|(_, record)| record) + } + /// Index of the first feature record tagged `tag`, as HarfBuzz's /// `find_feature_index` scans the FeatureList. pub(crate) fn find(&self, tag: [u8; 4]) -> Option { diff --git a/src/tables/layout/mod.rs b/src/tables/layout/mod.rs index 03f707af..459deb6b 100644 --- a/src/tables/layout/mod.rs +++ b/src/tables/layout/mod.rs @@ -6,6 +6,7 @@ //! in?". Every non-trivial lookup in GSUB and GPOS is built on top //! of one or both. +pub(crate) mod accel; pub mod class_def; pub mod context; pub mod coverage; From 63957d0cf3f0d4193f726a872c345bf66f4534d7 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 10:24:35 -0800 Subject: [PATCH 04/15] Keep vertical origins and phantom advances across calls 0.23.1 computes HarfBuzz's vertical origins for fonts without VORG from the glyph's ink extents, and advances of varied glyf fonts without HVAR or VVAR from phantom points. Both walk an outline (a CFF or CFF2 charstring, or gvar deltas), and both were recomputed on every call, which made repeated vertical runs 2x to 4x slower than in 0.23.0. A Font now keeps those values per glyph in direct-mapped caches, as HarfBuzz's hb_cache_t does: one atomic word per entry holding the glyph id and the value, so readers see a whole entry or none and need no lock. Each cache has at most 4096 entries (16 KiB) whatever the glyph count, values that do not fit an entry are not cached, and a font rebound with with_coords starts new caches while with_size keeps them. As with the layout caches, a font's first call builds none of it. --- src/font.rs | 2 +- src/font/cache.rs | 203 ++++++++++++++++++++++++++++++++++++++++-- src/shape/pipeline.rs | 8 +- src/shape/position.rs | 88 ++++++++++++++---- 4 files changed, 271 insertions(+), 30 deletions(-) diff --git a/src/font.rs b/src/font.rs index 1e4ebe13..9611d33a 100644 --- a/src/font.rs +++ b/src/font.rs @@ -38,7 +38,7 @@ mod cache; use alloc::vec::Vec; -pub(crate) use cache::FontCaches; +pub(crate) use cache::{FontCaches, GlyphValue, InstanceCache, Known}; use crate::error::Result; use crate::face::Face; diff --git a/src/font/cache.rs b/src/font/cache.rs index ac0ef327..e510dc99 100644 --- a/src/font/cache.rs +++ b/src/font/cache.rs @@ -12,16 +12,20 @@ //! [`crate::ot::layout_select`]). //! [`Font::with_coords`](super::Font::with_coords) and //! [`Font::with_size`](super::Font::with_size) keep it. +//! - [`InstanceCache`], which depends on the variation coordinates too: +//! the vertical origins and the advances that come from phantom +//! points, which cost an outline walk per glyph. `with_size` keeps it +//! and `with_coords` starts a new one. //! //! A font's first shaping call builds none of this: a font shaped once, //! which many callers build per run, would spend more building it than //! it saves. From the second call on, each part is built the first time //! a call needs it. //! -//! The cache is shared by every clone of the font made after it was -//! built, through an `Arc`, and every part is built through a -//! [`OnceBox`], so a font can be shared by shaping threads without a -//! lock. Whatever a cache holds is +//! Both caches are shared by every clone of the font made after they +//! were built, through an `Arc`, and every part is built through a +//! [`OnceBox`] or written with single atomic stores, so a font can be +//! shared by shaping threads without a lock. Whatever a cache holds is //! a pure function of the font data and coordinates, so a value one //! thread computes is the value any other would have, and shaping //! output does not depend on what is cached. @@ -29,11 +33,14 @@ //! Memory is bounded per font. For each of GSUB and GPOS: one pointer //! per lookup, 24 bytes per subtable of each lookup a run reached, and //! at most 16 resolved language systems of at most 1024 features and -//! 16384 lookup indices each. +//! 16384 lookup indices each. For the glyph caches: at most +//! [`GLYPH_CACHE_MAX`] four-byte entries for each of the three, +//! whatever the glyph count. +use alloc::boxed::Box; use alloc::sync::Arc; use core::fmt; -use core::sync::atomic::{AtomicBool, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use crate::sync::OnceBox; use crate::tables::layout::accel::LayoutCache; @@ -42,6 +49,7 @@ use crate::tables::layout::LayoutTable; /// The caches of one [`Font`](super::Font). pub(crate) struct FontCaches { face: OnceBox>, + instance: OnceBox>, /// Whether the font has been shaped with. used: AtomicBool, } @@ -51,6 +59,7 @@ impl FontCaches { pub(crate) const fn new() -> Self { Self { face: OnceBox::new(), + instance: OnceBox::new(), used: AtomicBool::new(false), } } @@ -68,15 +77,26 @@ impl FontCaches { self.face.get_or_init(|| Arc::new(FaceCache::new())) } + /// What depends on the coordinates too, created when first asked + /// for. + pub(crate) fn instance(&self) -> &InstanceCache { + self.instance.get_or_init(|| Arc::new(InstanceCache::new())) + } + /// The caches for a font with the same data at other coordinates: - /// the face cache shared if it exists. + /// the face cache shared if it exists, no instance cache. pub(crate) fn for_other_coords(&self) -> Self { - self.clone() + Self { + face: share(&self.face), + instance: OnceBox::new(), + used: AtomicBool::new(self.used.load(Ordering::Relaxed)), + } } /// Heap bytes the built caches hold. pub(crate) fn heap_bytes(&self) -> usize { self.face.get().map_or(0, |c| c.heap_bytes()) + + self.instance.get().map_or(0, |c| c.heap_bytes()) } } @@ -94,6 +114,7 @@ impl Clone for FontCaches { fn clone(&self) -> Self { Self { face: share(&self.face), + instance: share(&self.instance), used: AtomicBool::new(self.used.load(Ordering::Relaxed)), } } @@ -151,19 +172,185 @@ impl FaceCache { } } +/// What a font keeps that depends on its coordinates too: per-glyph +/// values whose computation walks an outline. +pub(crate) struct InstanceCache { + v_origins: OnceBox, + v_advances: OnceBox, + h_advances: OnceBox, +} + +/// Which per-glyph value of an [`InstanceCache`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GlyphValue { + /// The y of the vertical origin. + VOrigin, + /// The vertical advance from varied phantom points, if they could + /// be computed. + VPhantomAdvance, + /// The horizontal advance from varied phantom points, if they + /// could be computed. + HPhantomAdvance, +} + +impl InstanceCache { + const fn new() -> Self { + Self { + v_origins: OnceBox::new(), + v_advances: OnceBox::new(), + h_advances: OnceBox::new(), + } + } + + fn cache(&self, which: GlyphValue) -> &OnceBox { + match which { + GlyphValue::VOrigin => &self.v_origins, + GlyphValue::VPhantomAdvance => &self.v_advances, + GlyphValue::HPhantomAdvance => &self.h_advances, + } + } + + /// The cached `which` of glyph `gid`, if any. + pub(crate) fn get(&self, which: GlyphValue, gid: u16) -> Option { + self.cache(which).get()?.get(gid) + } + + /// Caches `which` of glyph `gid` in a font of `num_glyphs` glyphs. + /// A value outside the range a cache entry holds is not cached. + pub(crate) fn set(&self, which: GlyphValue, gid: u16, num_glyphs: u16, value: Option) { + self.cache(which) + .get_or_init(|| GlyphCache::new(num_glyphs)) + .set(gid, value); + } + + fn heap_bytes(&self) -> usize { + core::mem::size_of::() + + [&self.v_origins, &self.v_advances, &self.h_advances] + .iter() + .filter_map(|c| c.get()) + .map(GlyphCache::heap_bytes) + .sum::() + } +} + +/// A cached per-glyph value: `None` for a glyph whose value could not +/// be computed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Known(pub(crate) Option); + +/// Entries of the largest [`GlyphCache`]: 16 KiB. +pub(crate) const GLYPH_CACHE_MAX: usize = 4096; + +/// A direct-mapped cache of one `i16`-sized value per glyph, HarfBuzz's +/// `hb_cache_t`: entry `gid % len` holds the glyph id in its high half +/// and the value in its low half, in one atomic word, so a reader sees +/// a whole entry or none and needs no lock. A glyph that shares its +/// entry with another evicts it. +struct GlyphCache { + entries: Box<[AtomicU32]>, +} + +/// An entry no glyph has written: glyph id 0xFFFF, which a font never +/// has (it has at most 65535 glyphs) and [`GlyphCache::set`] skips. +const EMPTY: u32 = u32::MAX; +/// The stored value of a glyph whose value could not be computed. +const NONE: u16 = 0x8000; + +impl GlyphCache { + /// Entries for every glyph of a small font, [`GLYPH_CACHE_MAX`] for + /// a large one. + fn new(num_glyphs: u16) -> Self { + let len = usize::from(num_glyphs) + .max(1) + .next_power_of_two() + .min(GLYPH_CACHE_MAX); + Self { + entries: (0..len).map(|_| AtomicU32::new(EMPTY)).collect(), + } + } + + fn slot(&self, gid: u16) -> &AtomicU32 { + // The length is a power of two, never zero. + &self.entries[usize::from(gid) & (self.entries.len() - 1)] + } + + fn get(&self, gid: u16) -> Option { + let entry = self.slot(gid).load(Ordering::Relaxed); + if entry >> 16 != u32::from(gid) || gid == u16::MAX { + return None; + } + let stored = entry as u16; + Some(Known((stored != NONE).then_some(i32::from(stored as i16)))) + } + + fn set(&self, gid: u16, value: Option) { + let stored = match value { + None => NONE, + Some(v) => match i16::try_from(v) { + Ok(v) if v as u16 != NONE => v as u16, + _ => return, + }, + }; + if gid == u16::MAX { + return; + } + self.slot(gid) + .store(u32::from(gid) << 16 | u32::from(stored), Ordering::Relaxed); + } + + fn heap_bytes(&self) -> usize { + core::mem::size_of::() + self.entries.len() * core::mem::size_of::() + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn glyph_caches_round_trip_and_evict() { + let cache = GlyphCache::new(10); + assert_eq!(cache.entries.len(), 16); + assert_eq!(cache.get(3), None); + cache.set(3, Some(-120)); + cache.set(4, None); + cache.set(5, Some(i32::from(i16::MAX))); + assert_eq!(cache.get(3), Some(Known(Some(-120)))); + assert_eq!(cache.get(4), Some(Known(None))); + assert_eq!(cache.get(5), Some(Known(Some(i32::from(i16::MAX))))); + // Glyph 19 shares glyph 3's entry and evicts it. + cache.set(19, Some(7)); + assert_eq!(cache.get(3), None); + assert_eq!(cache.get(19), Some(Known(Some(7)))); + // Values an entry cannot hold are not cached. + cache.set(6, Some(40_000)); + cache.set(7, Some(i32::from(i16::MIN))); + assert_eq!(cache.get(6), None); + assert_eq!(cache.get(7), None); + cache.set(u16::MAX, Some(1)); + assert_eq!(cache.get(u16::MAX), None); + // Large fonts get a bounded cache. + assert_eq!(GlyphCache::new(u16::MAX).entries.len(), GLYPH_CACHE_MAX); + assert_eq!(GlyphCache::new(0).entries.len(), 1); + } + #[test] fn caches_are_shared_once_built() { let caches = FontCaches::new(); let empty = caches.clone(); let face = core::ptr::from_ref(caches.face()); + caches + .instance() + .set(GlyphValue::VOrigin, 1, 100, Some(880)); let shared = caches.clone(); assert!(core::ptr::eq(shared.face(), face)); + assert_eq!( + shared.instance().get(GlyphValue::VOrigin, 1), + Some(Known(Some(880))) + ); let other = caches.for_other_coords(); assert!(core::ptr::eq(other.face(), face)); + assert_eq!(other.instance().get(GlyphValue::VOrigin, 1), None); // A copy made before anything was built builds its own. assert!(!core::ptr::eq(empty.face(), face)); assert!(caches.heap_bytes() > 0); diff --git a/src/shape/pipeline.rs b/src/shape/pipeline.rs index 3c2438f2..96967bb3 100644 --- a/src/shape/pipeline.rs +++ b/src/shape/pipeline.rs @@ -251,8 +251,9 @@ pub fn shape(font: &Font<'_>, buffer: &Buffer, features: &[Feature]) -> Result, buffer: &Buffer, features: &[Feature]) -> Result>, adjust_offset /// `vmtx` cannot fail it. /// /// The pipeline builds one per shaping call and hands it to the -/// vertical origins, the fallback spaces, and the `stch` stretch. +/// vertical origins, the fallback spaces, and the `stch` stretch. The +/// values that walk an outline (vertical origins, and advances from +/// phantom points) are also kept in the font's [`InstanceCache`] for +/// later calls, as HarfBuzz keeps them for a font. pub(super) struct FontAdvances<'a, 'c> { face: &'c Face<'a>, coords: &'c [f32], @@ -391,6 +395,13 @@ pub(super) struct FontAdvances<'a, 'c> { v_origins: RefCell>, /// The tables glyph extents come from, read when first needed. extents: ExtentsTables<'a>, + /// The font's cache of per-glyph values at these coords, if any. + cache: Option<&'c InstanceCache>, + /// The font's glyph count, which sizes `cache`; read when first + /// needed. + num_glyphs: OnceCell, + /// The run is vertical. + vertical: bool, } /// A glyph's four phantom points: left and right in x, top and bottom @@ -410,7 +421,15 @@ impl<'a, 'c> FontAdvances<'a, 'c> { /// Reads the metrics tables the advances of `face` at `coords` come /// from. A `vertical` run also reads `vmtx`, `VORG` and `VVAR`; a /// horizontal one never does. - pub(super) fn new(face: &'c Face<'a>, coords: &'c [f32], vertical: bool) -> Result { + /// + /// `cache` is the font's cache for these coords: values found there + /// are not computed again, and values computed are added to it. + pub(super) fn new( + face: &'c Face<'a>, + coords: &'c [f32], + vertical: bool, + cache: Option<&'c InstanceCache>, + ) -> Result { let varied = coords.iter().any(|&c| c != 0.0); let (vmtx, vorg) = if vertical { (face.vmtx()?, face.vorg()?) @@ -450,9 +469,37 @@ impl<'a, 'c> FontAdvances<'a, 'c> { v_phantom: RefCell::default(), v_origins: RefCell::default(), extents: ExtentsTables::default(), + cache, + num_glyphs: OnceCell::new(), + vertical, }) } + /// `which` of glyph `id` from the font's cache, else computed with + /// `compute` and added to the cache. + fn cached( + &self, + which: GlyphValue, + id: u16, + compute: impl FnOnce() -> Option, + ) -> Option { + // The vertical values come from `vmtx`, `VORG` and `VVAR`, which + // only a vertical run reads, so only a vertical run shares them. + let vertical_value = matches!(which, GlyphValue::VOrigin | GlyphValue::VPhantomAdvance); + let Some(cache) = self.cache.filter(|_| self.vertical || !vertical_value) else { + return compute(); + }; + if let Some(Known(value)) = cache.get(which, id) { + return value; + } + let value = compute(); + let num_glyphs = *self + .num_glyphs + .get_or_init(|| self.face.maxp().map_or(u16::MAX, |maxp| maxp.num_glyphs)); + cache.set(which, id, num_glyphs, value); + value + } + /// The ink extents of glyph `id` at the call's coords, as HarfBuzz's /// `hb_font_get_glyph_extents` computes them, or `None` when the /// font has no `glyf`, `CFF ` or `CFF2` (see @@ -498,10 +545,11 @@ impl<'a, 'c> FontAdvances<'a, 'c> { if !self.outlines().is_some_and(|t| t.gvar.is_some()) { return base; } - match self.v_phantoms(id) { - Some(pp) => hb_round(pp[2].1 - pp[3].1).max(0), - None => base, - } + self.cached(GlyphValue::VPhantomAdvance, id, || { + self.v_phantoms(id) + .map(|pp| hb_round(pp[2].1 - pp[3].1).max(0)) + }) + .unwrap_or(base) } /// The y of glyph `id`'s vertical origin, in font units, as @@ -541,7 +589,9 @@ impl<'a, 'c> FontAdvances<'a, 'c> { { return origin; } - let origin = self.compute_v_origin(id); + let origin = self + .cached(GlyphValue::VOrigin, id, || Some(self.compute_v_origin(id))) + .unwrap_or_else(|| self.compute_v_origin(id)); if let Ok(mut cache) = self.v_origins.try_borrow_mut() { cache.insert(id, origin); } @@ -607,17 +657,19 @@ impl<'a, 'c> FontAdvances<'a, 'c> { if let Some(&advance) = self.h_phantom.try_borrow().ok()?.get(&id) { return advance; } - let advance = self.outlines().and_then(|t| { - let gvar = t.gvar.as_ref()?; - let metrics = PhantomMetrics { - hmtx: &self.hmtx, - vmtx: None, - }; - let pp = t - .glyf - .phantom_points_at_coords(&t.loca, id, Some(gvar), self.coords, &metrics) - .ok()?; - Some(hb_round(pp[1].0 - pp[0].0).max(0)) + let advance = self.cached(GlyphValue::HPhantomAdvance, id, || { + self.outlines().and_then(|t| { + let gvar = t.gvar.as_ref()?; + let metrics = PhantomMetrics { + hmtx: &self.hmtx, + vmtx: None, + }; + let pp = t + .glyf + .phantom_points_at_coords(&t.loca, id, Some(gvar), self.coords, &metrics) + .ok()?; + Some(hb_round(pp[1].0 - pp[0].0).max(0)) + }) }); if let Ok(mut cache) = self.h_phantom.try_borrow_mut() { cache.insert(id, advance); From 64b29aa407edec3d8002a56dd6db0cb383902d0a Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 10:24:35 -0800 Subject: [PATCH 05/15] Test that Font caches never change shaping output Shapes texts of nine fixtures, horizontally and vertically, with and without feature overrides, through one Font twice over and compares every call with a fresh Font's. Eight threads share one Font and walk the texts in different orders. A font rebound to other coordinates must not reuse the old instance's metrics, and Font and Face must stay Send + Sync. --- tests/font_caches.rs | 286 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 tests/font_caches.rs diff --git a/tests/font_caches.rs b/tests/font_caches.rs new file mode 100644 index 00000000..be75404a --- /dev/null +++ b/tests/font_caches.rs @@ -0,0 +1,286 @@ +//! A `Font` keeps lookup accelerators, resolved language systems and +//! per-glyph vertical metrics between shaping calls, from its second +//! call on. What they hold must never show in the output: a font shaped +//! with many times, from many threads at once, or rebound to other +//! coordinates gives what a fresh font gives. + +use std::sync::Arc; + +use sigilbuzz::{shape, Blob, Buffer, Direction, Face, Feature, Font}; + +/// A font fixture, the user-space axis values to shape it at, and the +/// scripts to draw its texts from. +struct Case { + name: &'static str, + data: &'static [u8], + axes: &'static [([u8; 4], f32)], +} + +const CASES: &[Case] = &[ + Case { + name: "opensans", + data: include_bytes!("fixtures/opensans_regular.ttf"), + axes: &[], + }, + Case { + name: "amiri", + data: include_bytes!("fixtures/amiri_regular.ttf"), + axes: &[], + }, + Case { + name: "devanagari", + data: include_bytes!("fonts/NotoSansDevanagari-Regular.ttf"), + axes: &[], + }, + Case { + name: "source code pro (CFF, no VORG)", + data: include_bytes!("fonts/SourceCodePro-Latin-Subset.otf"), + axes: &[], + }, + Case { + name: "source sans 3 (CFF2, no VORG)", + data: include_bytes!("fonts/SourceSans3VF-Latin-Subset.otf"), + axes: &[(*b"wght", 650.0)], + }, + Case { + name: "hahmlet (glyf, gvar, no vmtx)", + data: include_bytes!("fixtures/hahmlet_gvar_subset.ttf"), + axes: &[(*b"wght", 700.0)], + }, + Case { + name: "rubik (glyf, gvar, no vmtx)", + data: include_bytes!("fixtures/rubik_vf.ttf"), + axes: &[(*b"wght", 420.0)], + }, + Case { + name: "noto sans kr (CFF2, VORG)", + data: include_bytes!("fixtures/noto_sans_kr_vf_vertical_subset.otf"), + axes: &[(*b"wght", 550.0)], + }, + Case { + name: "noto sans kr palt", + data: include_bytes!("fonts/NotoSansKR-Palt-Subset.ttf"), + axes: &[], + }, +]; + +/// Normalized coords for `axes`, through `fvar` and `avar`. +fn coords(face: &Face<'_>, axes: &[([u8; 4], f32)]) -> Vec { + let Some(fvar) = face.fvar().unwrap() else { + return Vec::new(); + }; + let user: Vec = fvar + .axes() + .iter() + .map(|a| { + axes.iter() + .find(|(tag, _)| *tag == a.tag) + .map_or(a.default_value, |&(_, v)| v) + }) + .collect(); + let normalized = fvar.normalize_coords(&user); + match face.avar().unwrap() { + Some(avar) => avar.remap_all(&normalized), + None => normalized, + } +} + +/// Texts of 1, 3, 8 and 30 characters the font maps, picked with a +/// fixed linear congruential generator. +fn texts(face: &Face<'_>) -> Vec { + let cmap = face.cmap().unwrap(); + let ranges = [ + 0x20..0x7F, + 0x600..0x660, + 0x900..0x970, + 0xAC00..0xAE00, + 0x3000..0x3040, + ]; + let pool: Vec = ranges + .into_iter() + .flatten() + .filter_map(char::from_u32) + .filter(|&c| cmap.glyph_id(c).is_some()) + .collect(); + assert!(!pool.is_empty()); + let mut seed = 0x2545_F491u32; + let mut next = |n: usize| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 8) as usize % n + }; + let mut out = Vec::new(); + for len in [1, 3, 8, 30] { + for _ in 0..12 { + out.push((0..len).map(|_| pool[next(pool.len())]).collect()); + } + } + out +} + +type Shaped = Vec<(u32, u32, i32, i32, i32, i32)>; + +fn run(font: &Font<'_>, text: &str, vertical: bool, features: &[Feature]) -> Shaped { + let mut buffer = Buffer::new(); + buffer.push_str(text); + buffer.guess_segment_properties(); + if vertical { + buffer.set_direction(Direction::Ttb); + } + shape(font, &buffer, features) + .unwrap() + .glyphs + .iter() + .map(|g| { + ( + g.glyph_id, + g.cluster, + g.x_advance, + g.y_advance, + g.x_offset, + g.y_offset, + ) + }) + .collect() +} + +/// Each text shaped by a font of its own, which builds no cache. +fn fresh( + face: &Face<'_>, + coords: &[f32], + texts: &[String], + vertical: bool, + features: &[Feature], +) -> Vec { + texts + .iter() + .map(|t| { + let font = Font::new(face.clone(), 1000.0).with_coords(coords); + run(&font, t, vertical, features) + }) + .collect() +} + +const FEATURE_SETS: [&[Feature]; 3] = [ + &[], + &[ + Feature { + tag: *b"liga", + value: 0, + }, + Feature { + tag: *b"kern", + value: 0, + }, + ], + &[Feature { + tag: *b"smcp", + value: 1, + }], +]; + +#[test] +fn a_font_shaped_with_again_shapes_like_a_fresh_one() { + for case in CASES { + let face = Face::parse_bytes(case.data, 0).unwrap(); + let coords = coords(&face, case.axes); + let texts = texts(&face); + let font = Font::new(face.clone(), 1000.0).with_coords(&coords); + for vertical in [false, true] { + for features in FEATURE_SETS { + let expected = fresh(&face, &coords, &texts, vertical, features); + // Twice over: the first round fills the caches, the + // second reads them. + for round in 0..2 { + for (text, want) in texts.iter().zip(&expected) { + let got = run(&font, text, vertical, features); + assert_eq!( + &got, want, + "{} {text:?} vertical={vertical} round {round}", + case.name + ); + } + } + } + } + } +} + +#[test] +fn threads_sharing_one_font_agree() { + for case in CASES { + let face = Face::parse_bytes(case.data, 0).unwrap(); + let coords: Arc<[f32]> = coords(&face, case.axes).into(); + let texts = texts(&face); + let expected: Vec> = [false, true] + .iter() + .map(|&v| fresh(&face, &coords, &texts, v, &[])) + .collect(); + let font = Font::new(face, 1000.0).with_coords(&coords); + std::thread::scope(|scope| { + for t in 0..8 { + let (font, texts, expected) = (&font, &texts, &expected); + scope.spawn(move || { + for round in 0..4 { + // Each thread walks the texts in its own order. + for k in 0..texts.len() { + let i = (k * (2 * t + 1) + round) % texts.len(); + let vertical = (i + t) % 2 == 1; + let got = run(font, &texts[i], vertical, &[]); + assert_eq!( + got, + expected[usize::from(vertical)][i], + "{} thread {t} text {i}", + case.name + ); + } + } + }); + } + }); + } +} + +#[test] +fn rebinding_coords_does_not_reuse_the_old_instance() { + let case = CASES + .iter() + .find(|c| c.name.starts_with("hahmlet")) + .unwrap(); + let face = Face::parse_bytes(case.data, 0).unwrap(); + let light = coords(&face, &[(*b"wght", 100.0)]); + let heavy = coords(&face, &[(*b"wght", 900.0)]); + let texts = texts(&face); + let font = Font::new(face.clone(), 1000.0).with_coords(&light); + for vertical in [true, false] { + for text in &texts { + run(&font, text, vertical, &[]); + run(&font, text, vertical, &[]); + } + } + let rebound = font.clone().with_coords(&heavy); + let resized = font.with_size(12.0); + for vertical in [true, false] { + let want_heavy = fresh(&face, &heavy, &texts, vertical, &[]); + let want_light = fresh(&face, &light, &texts, vertical, &[]); + for (i, text) in texts.iter().enumerate() { + assert_eq!( + run(&rebound, text, vertical, &[]), + want_heavy[i], + "{text:?}" + ); + assert_eq!( + run(&resized, text, vertical, &[]), + want_light[i], + "{text:?}" + ); + } + } +} + +#[test] +fn fonts_and_faces_stay_send_and_sync() { + fn check() {} + check::>(); + check::>(); + check::>(); +} From 5211d285473befb7d3d1842928d00907f0dfeb49 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 10:28:30 -0800 Subject: [PATCH 06/15] Add Font::with_variations for user-space axis values Callers had to run fvar and avar by hand to bind a variable instance, and had to keep the normalized coords alive for the Font's lifetime because with_coords borrows them. oneiric does this by hand, and oniq's backend test skips the avar step. with_variations takes (tag, value) pairs as hb_font_set_variations does: axes start at their defaults, each pair sets every axis with its tag (a later pair wins), unknown tags are ignored, and the values go through the same fvar and avar steps with HarfBuzz's 16.16 rounding that shaping then rounds to F2DOT14. The Font owns the result, so Font::coords now returns a slice borrowed from the Font rather than one that lives for 'a. A face without a readable fvar gives the default instance, and an unreadable avar is left out, as HarfBuzz's sanitizer drops them. --- src/font.rs | 195 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 163 insertions(+), 32 deletions(-) diff --git a/src/font.rs b/src/font.rs index 9611d33a..26a8c642 100644 --- a/src/font.rs +++ b/src/font.rs @@ -7,35 +7,30 @@ //! pick the right advance widths (via HVAR) and, on the road map, //! outline deltas (via gvar). //! -//! Callers start from user-space axis values (e.g. `wght = 700`, -//! `wdth = 80`) and pass them through the `fvar` / `avar` pipeline to -//! get normalized coords: +//! Callers usually start from user-space axis values (e.g. `wght = 700`, +//! `wdth = 80`), which [`Font::with_variations`] takes, as HarfBuzz's +//! `hb_font_set_variations` does: //! //! ```text -//! let fvar = face.fvar()?.unwrap(); -//! let avar = face.avar()?; -//! let coords = fvar.normalize_coords(&[700.0, 80.0]); -//! let coords = match avar { -//! Some(a) => a.remap_all(&coords), -//! None => coords, -//! }; -//! let font = Font::new(face, 16.0).with_coords(&coords); +//! let font = Font::new(face, 16.0).with_variations(&[(*b"wght", 700.0), (*b"wdth", 80.0)]); //! ``` //! -//! These are the steps of HarfBuzz's `hb_ot_var_normalize_coords`: -//! `normalize_coords` rounds each coord to 16.16 fixed point and -//! `remap_all` maps it through `avar` and rounds it to 16.16 again. -//! Shaping then rounds every coord to F2DOT14 (a multiple of 1/16384, -//! halves up), the precision HarfBuzz stores coords in, so the same -//! design-space values give the same instance as in HarfBuzz. +//! It runs the steps of HarfBuzz's `hb_ot_var_normalize_coords`: +//! [`crate::tables::Fvar::normalize_coords`] rounds each coord to 16.16 +//! fixed point and [`crate::tables::Avar::remap_all`] maps it through +//! `avar` and rounds it to 16.16 again. Shaping then rounds every coord +//! to F2DOT14 (a multiple of 1/16384, halves up), the precision +//! HarfBuzz stores coords in, so the same design-space values give the +//! same instance as in HarfBuzz. A caller that already has normalized +//! coords binds them with [`Font::with_coords`], which borrows them for +//! the `Font`'s lifetime. //! -//! The coord slice is borrowed for the `Font`'s lifetime, so callers -//! own the storage. A `Font` with an empty coord slice behaves -//! identically to the static default instance, and so does one whose -//! coords all round to zero. +//! A `Font` with no coords behaves identically to the static default +//! instance, and so does one whose coords all round to zero. mod cache; +use alloc::sync::Arc; use alloc::vec::Vec; pub(crate) use cache::{FontCaches, GlyphValue, InstanceCache, Known}; @@ -71,15 +66,38 @@ pub(crate) fn f2dot14_coords(coords: &[f32]) -> Vec { } /// A face bound to a render size (and optional variation coords). +/// +/// A `Font` keeps lookup accelerators and glyph metrics between shaping +/// calls, built from its second call on, so a caller that shapes many +/// runs with one font should keep the `Font` rather than build one per +/// run. It is `Send + Sync`: threads may share one font, and its clones +/// share what it has built. #[derive(Debug, Clone)] pub struct Font<'a> { face: Face<'a>, size: f32, - coords: &'a [f32], + coords: Coords<'a>, /// Lookup accelerators and metrics kept between shaping calls. caches: FontCaches, } +/// A font's normalized coords: the caller's, or computed from +/// user-space values by [`Font::with_variations`]. +#[derive(Debug, Clone)] +enum Coords<'a> { + Borrowed(&'a [f32]), + Owned(Arc<[f32]>), +} + +impl Coords<'_> { + fn as_slice(&self) -> &[f32] { + match self { + Self::Borrowed(coords) => coords, + Self::Owned(coords) => coords, + } + } +} + impl<'a> Font<'a> { /// Creates a new font from a parsed face and a size. /// @@ -93,7 +111,7 @@ impl<'a> Font<'a> { Self { face, size, - coords: &[], + coords: Coords::Borrowed(&[]), caches: FontCaches::new(), } } @@ -113,9 +131,13 @@ impl<'a> Font<'a> { /// Current normalized variation coords. Empty when the font is /// bound to its default instance (or when the face is not a /// variable font at all). + /// + /// Since 0.24.0 the slice borrows the font rather than living for + /// `'a`: a font built with [`Font::with_variations`] owns its + /// coords. #[must_use] - pub const fn coords(&self) -> &'a [f32] { - self.coords + pub fn coords(&self) -> &[f32] { + self.coords.as_slice() } /// Returns a new `Font` at a different size, sharing the same @@ -125,7 +147,7 @@ impl<'a> Font<'a> { Self { face: self.face.clone(), size, - coords: self.coords, + coords: self.coords.clone(), caches: self.caches.clone(), } } @@ -136,11 +158,9 @@ impl<'a> Font<'a> { /// each coord to F2DOT14 (a multiple of 1/16384, halves up), as /// HarfBuzz stores coords; [`Font::coords`] returns them as given. /// - /// **User-space values are not accepted here.** If the caller has - /// raw design-space values (e.g. `wght = 700.0`), they must first - /// pass them through [`crate::tables::Fvar::normalize_coords`] and - /// then through [`crate::tables::Avar::remap_all`] when the face - /// carries an avar table. Passing raw user-space values to this + /// **User-space values are not accepted here.** For raw + /// design-space values (e.g. `wght = 700.0`) use + /// [`Font::with_variations`]. Passing raw user-space values to this /// method silently yields wrong deltas because the ItemVariationStore /// only reads normalized input. #[must_use] @@ -149,7 +169,72 @@ impl<'a> Font<'a> { caches: self.caches.for_other_coords(), face: self.face, size: self.size, - coords, + coords: Coords::Borrowed(coords), + } + } + + /// Returns a new `Font` at the instance user-space axis values + /// `variations` select, as HarfBuzz's `hb_font_set_variations` + /// does: every axis starts at its `fvar` default, each `(tag, + /// value)` sets every axis with that tag (a later entry wins), and + /// the values are normalized through `fvar` and `avar` with + /// HarfBuzz's rounding (see the module docs). Tags the font has no + /// axis for are ignored, values outside an axis's range are + /// clamped to it, and an empty list selects the default instance. + /// + /// A face without `fvar`, or whose `fvar` does not parse, has no + /// axes, so the font is its default instance. An `avar` that does + /// not parse is left out. HarfBuzz's sanitizer drops both tables + /// in those cases. + /// + /// ``` + /// use sigilbuzz::{Face, Font}; + /// + /// let data = include_bytes!("../tests/fixtures/rubik_vf.ttf"); + /// let face = Face::parse_bytes(data, 0)?; + /// let font = Font::new(face.clone(), 16.0).with_variations(&[(*b"wght", 650.0)]); + /// + /// // The same coords, normalized by hand. + /// let fvar = face.fvar()?.expect("Rubik is variable"); + /// let user: Vec = fvar + /// .axes() + /// .iter() + /// .map(|a| if &a.tag == b"wght" { 650.0 } else { a.default_value }) + /// .collect(); + /// let normalized = fvar.normalize_coords(&user); + /// let normalized = match face.avar()? { + /// Some(avar) => avar.remap_all(&normalized), + /// None => normalized, + /// }; + /// assert_eq!(font.coords(), normalized.as_slice()); + /// # Ok::<(), sigilbuzz::Error>(()) + /// ``` + #[must_use] + pub fn with_variations(self, variations: &[([u8; 4], f32)]) -> Self { + let coords: Vec = match self.face.fvar() { + Ok(Some(fvar)) => { + let axes = fvar.axes(); + let mut user: Vec = axes.iter().map(|a| a.default_value).collect(); + for &(tag, value) in variations { + for (axis, slot) in axes.iter().zip(user.iter_mut()) { + if axis.tag == tag { + *slot = value; + } + } + } + let normalized = fvar.normalize_coords(&user); + match self.face.avar() { + Ok(Some(avar)) => avar.remap_all(&normalized), + _ => normalized, + } + } + _ => Vec::new(), + }; + Self { + caches: self.caches.for_other_coords(), + face: self.face, + size: self.size, + coords: Coords::Owned(coords.into()), } } @@ -239,4 +324,50 @@ mod tests { assert!(f2dot14_coords(&[]).is_empty()); assert_eq!(f2dot14_coords(&[f32::NAN, 0.5]), [0.0, 0.5]); } + + /// The coords `with_variations` should give `face` for `user`, one + /// user value per axis, normalized by hand. + fn by_hand(face: &Face<'_>, user: &[f32]) -> Vec { + let normalized = face.fvar().unwrap().unwrap().normalize_coords(user); + match face.avar().unwrap() { + Some(avar) => avar.remap_all(&normalized), + None => normalized, + } + } + + #[test] + fn variations_normalize_like_harfbuzz() { + let data = include_bytes!("../tests/fixtures/rubik_vf.ttf"); + let face = Face::parse_bytes(data, 0).unwrap(); + let axis = face.fvar().unwrap().unwrap().axes()[0]; + assert_eq!(&axis.tag, b"wght"); + let font = |v: &[([u8; 4], f32)]| Font::new(face.clone(), 16.0).with_variations(v); + assert_eq!( + font(&[(*b"wght", 650.0)]).coords(), + by_hand(&face, &[650.0]) + ); + // A later entry for the same tag wins, unknown tags are ignored, + // and values past the axis range are clamped to it. + let later = font(&[(*b"wght", 300.0), (*b"XXXX", 5.0), (*b"wght", 650.0)]); + assert_eq!(later.coords(), by_hand(&face, &[650.0])); + let clamped = font(&[(*b"wght", 5000.0)]); + assert_eq!(clamped.coords(), by_hand(&face, &[axis.max_value])); + assert_eq!(clamped.coords(), [1.0]); + // No variations is the default instance. + assert_eq!(font(&[]).coords(), [0.0]); + assert!(f2dot14_coords(font(&[]).coords()).is_empty()); + // The coords survive a size change, and with_coords replaces them. + let resized = font(&[(*b"wght", 650.0)]).with_size(32.0); + assert_eq!(resized.coords(), by_hand(&face, &[650.0])); + let rebound = resized.with_coords(&[0.25]); + assert_eq!(rebound.coords(), [0.25]); + } + + #[test] + fn variations_on_a_static_face_select_the_default_instance() { + let data = minimal_face_bytes(); + let face = Face::parse_bytes(&data, 0).unwrap(); + let font = Font::new(face, 16.0).with_variations(&[(*b"wght", 700.0)]); + assert!(font.coords().is_empty()); + } } From c98a486c43355676c44dbca5d4fb9375e139fa8d Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 10:31:52 -0800 Subject: [PATCH 07/15] Add Face::glyph_outlines to draw a run with the tables read once Face::glyph_outline_at_coords parses the outline tables for every glyph: for a CFF2 font, the table, its variation store, the Private DICTs, and the region scalars of the coords, again for each glyph of a run. oneiric keeps its own Cff2 per run to avoid it, and loses the F2DOT14 rounding and VARC handling the face method has. GlyphOutlines keeps the table the face method would draw from (CFF2 with the shared state the shaper's extents already use, CFF, or glyf with loca, gvar and the phantom-point metrics) and VARC, read on first use. outline() returns what glyph_outline_at_coords returns for every glyph, errors included, and draw() streams the same ops into any OutlineSink. VARC composites are drawn by the face method, since their components carry coords of their own. --- docs/STABILITY.md | 6 + src/face.rs | 2 + src/face/glyph_outlines.rs | 219 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 +- tests/glyph_outlines.rs | 122 +++++++++++++++++++++ 5 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 src/face/glyph_outlines.rs create mode 100644 tests/glyph_outlines.rs diff --git a/docs/STABILITY.md b/docs/STABILITY.md index 4fa533b2..90e0ad67 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -89,6 +89,12 @@ signatures only change in a major version. cluster levels. A Rust `Buffer` defaults to `MonotoneCharacters`; the C API defaults to HarfBuzz's `MONOTONE_GRAPHEMES`. Both settings survive `Buffer::clear`. +- Added in 0.24.0: + - `Font::with_variations`: bind a variable instance from user-space axis values, as + HarfBuzz's `hb_font_set_variations` does. `Font::coords` now returns a slice that + borrows the font, since such a font owns its coords. + - `Face::glyph_outlines` and `GlyphOutlines::{outline, draw}`: draw many glyphs at + one instance with the outline tables read once. `sigilbuzz-render`: diff --git a/src/face.rs b/src/face.rs index c864abc9..83218b28 100644 --- a/src/face.rs +++ b/src/face.rs @@ -33,6 +33,7 @@ //! with [`crate::fonts_in_collection`]. mod color; +mod glyph_outlines; mod layout; mod outlines; mod variations; @@ -45,6 +46,7 @@ use crate::tables::parse::Reader; use crate::tables::{tag, Cmap, Head, Hhea, Hmtx, Maxp, Name, Vhea, Vmtx, Vorg}; pub use color::GlyphBitmapEntry; +pub use glyph_outlines::GlyphOutlines; pub(crate) use outlines::varied_glyph_bounds; /// One entry in the SFNT table directory. diff --git a/src/face/glyph_outlines.rs b/src/face/glyph_outlines.rs new file mode 100644 index 00000000..b6d891a7 --- /dev/null +++ b/src/face/glyph_outlines.rs @@ -0,0 +1,219 @@ +//! [`GlyphOutlines`]: many glyphs of one face drawn at one instance, +//! with the outline tables read once. + +use core::cell::OnceCell; +use core::fmt; + +use alloc::vec::Vec; + +use super::Face; +use crate::error::Result; +use crate::font::f2dot14_coords; +use crate::tables::cff2::Cff2Shared; +use crate::tables::glyf::PhantomMetrics; +use crate::tables::{ + tag, Cff, Cff2, Glyf, Gvar, Hmtx, Loca, Outline, OutlineSink, PathOp, Varc, Vmtx, +}; + +/// Draws glyphs of one face at one set of variation coords, reading the +/// outline tables once for all of them. +/// +/// [`Face::glyph_outline_at_coords`] reads the tables for every glyph, +/// which for a `CFF2` font means parsing the table, its variation store, +/// its Private DICTs, and the region scalars of the coords again and +/// again. A run of text draws many glyphs at one instance, so this keeps +/// them: the `CFF2` or `CFF ` table, or `glyf` with `loca`, `gvar` and +/// the metrics its phantom points read, and `VARC`. Each glyph comes out +/// exactly as [`Face::glyph_outline_at_coords`] draws it, errors +/// included: a table that does not read fails every glyph that needs it. +/// +/// It caches through cells that are not thread-safe, so it is `Send` but +/// not `Sync`: build one per thread. +/// +/// ``` +/// use sigilbuzz::Face; +/// +/// let data = include_bytes!("../../tests/fixtures/noto_sans_kr_vf_cff2_subset.otf"); +/// let face = Face::parse_bytes(data, 0)?; +/// let coords = [0.5]; +/// let outlines = face.glyph_outlines(&coords); +/// for gid in 0..face.maxp()?.num_glyphs { +/// assert_eq!( +/// outlines.outline(gid)?, +/// face.glyph_outline_at_coords(gid, &coords)?, +/// ); +/// } +/// # Ok::<(), sigilbuzz::Error>(()) +/// ``` +pub struct GlyphOutlines<'a> { + face: Face<'a>, + /// The coords as shaping reads them, rounded to F2DOT14. + coords: Vec, + varc: OnceCell>>>, + tables: OnceCell>>, +} + +/// The table the glyphs are drawn from, chosen as +/// [`Face::glyph_outline_at_coords`] chooses it. +enum Tables<'a> { + /// With what its glyphs share at the coords. + Cff2(Cff2<'a>, Cff2Shared<'a>), + Cff(Cff<'a>), + Glyf { + glyf: Glyf<'a>, + loca: Loca<'a>, + hmtx: Hmtx<'a>, + /// The font's `vmtx`, or HarfBuzz's metrics for a font without. + vmtx: Vmtx<'a>, + /// `None` at the default instance. + gvar: Option>, + }, +} + +impl<'a> Face<'a> { + /// Returns a [`GlyphOutlines`] that draws this face's glyphs at the + /// normalized variation coords `coords`, reading the outline tables + /// once. The coords are rounded to F2DOT14 as + /// [`Face::glyph_outline_at_coords`] rounds them; an empty slice + /// draws the default instance. + #[must_use] + pub fn glyph_outlines(&self, coords: &[f32]) -> GlyphOutlines<'a> { + GlyphOutlines { + face: self.clone(), + coords: f2dot14_coords(coords), + varc: OnceCell::new(), + tables: OnceCell::new(), + } + } +} + +impl<'a> GlyphOutlines<'a> { + /// The outline of `glyph_id`, as [`Face::glyph_outline_at_coords`] + /// returns it: `Ok(None)` for a glyph with no outline or past the + /// end of the outline table. + /// + /// # Errors + /// + /// The errors [`Face::glyph_outline_at_coords`] returns for the + /// glyph. + pub fn outline(&self, glyph_id: u16) -> Result> { + let mut out = Outline::new(); + let drew = self.draw(glyph_id, &mut out)?; + Ok(drew.then_some(out)) + } + + /// Draws `glyph_id` into `sink` and returns whether the glyph has an + /// outline, the ops [`GlyphOutlines::outline`] would return, without + /// collecting them. A glyph that fails partway may have sent some + /// ops to `sink` before the error. + /// + /// # Errors + /// + /// The errors [`Face::glyph_outline_at_coords`] returns for the + /// glyph. + pub fn draw(&self, glyph_id: u16, sink: &mut S) -> Result { + let varc = self + .varc + .get_or_init(|| self.face.varc()) + .as_ref() + .map_err(Clone::clone)?; + // A VARC composite is drawn the way the face draws it: its + // components carry coords of their own. + if let Some(varc) = varc { + if varc.covers(glyph_id) && varc.composite(glyph_id, &self.coords).is_some() { + let Some(outline) = self.face.glyph_outline_at_coords(glyph_id, &self.coords)? + else { + return Ok(false); + }; + replay(&outline, sink); + return Ok(true); + } + } + let tables = self + .tables + .get_or_init(|| self.read_tables()) + .as_ref() + .map_err(Clone::clone)?; + match tables { + Tables::Cff2(cff2, shared) => cff2.outline_shared(glyph_id, &self.coords, shared, sink), + Tables::Cff(cff) => cff.outline(glyph_id, sink), + Tables::Glyf { + glyf, + loca, + hmtx, + vmtx, + gvar, + } => { + let metrics = PhantomMetrics { + hmtx, + vmtx: Some(vmtx), + }; + glyf.outline_at_coords( + loca, + glyph_id, + gvar.as_ref(), + &self.coords, + Some(&metrics), + sink, + ) + } + } + } + + /// Reads the table the glyphs come from, in the order + /// [`Face::glyph_outline_at_coords`] reads them. + fn read_tables(&self) -> Result> { + let face = &self.face; + if face.record(tag::CFF2).is_some() { + return Ok(Tables::Cff2(face.cff2()?, Cff2Shared::default())); + } + if face.record(tag::CFF1).is_some() && face.record(tag::GLYF).is_none() { + return Ok(Tables::Cff(face.cff()?)); + } + let loca = face.loca()?; + let glyf = face.glyf()?; + let hmtx = face.hmtx()?; + let vmtx = face.vmtx()?; + let vmtx = face.phantom_vmtx(vmtx)?; + let gvar = if self.coords.is_empty() { + None + } else { + face.gvar()? + }; + Ok(Tables::Glyf { + glyf, + loca, + hmtx, + vmtx, + gvar, + }) + } +} + +/// Sends the ops of `outline` to `sink`. +fn replay(outline: &Outline, sink: &mut S) { + for op in outline.ops() { + match *op { + PathOp::MoveTo { x, y } => sink.move_to(x, y), + PathOp::LineTo { x, y } => sink.line_to(x, y), + PathOp::QuadTo { cx, cy, x, y } => sink.quad_to(cx, cy, x, y), + PathOp::CubicTo { + c1x, + c1y, + c2x, + c2y, + x, + y, + } => sink.curve_to(c1x, c1y, c2x, c2y, x, y), + PathOp::Close => sink.close(), + } + } +} + +impl fmt::Debug for GlyphOutlines<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GlyphOutlines") + .field("coords", &self.coords) + .finish_non_exhaustive() + } +} diff --git a/src/lib.rs b/src/lib.rs index f3e4a537..7d344b99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,7 +96,7 @@ pub use bidi::{BidiParagraph, BidiParagraphSpan, BidiRun, ShapedBidiRun}; pub use blob::Blob; pub use buffer::{Buffer, BufferFlags, ClusterLevel, Direction, Glyph, GlyphFlags, ShapedRun}; pub use error::{Error, Result}; -pub use face::{Face, GlyphBitmapEntry}; +pub use face::{Face, GlyphBitmapEntry, GlyphOutlines}; pub use font::Font; pub use language::Language; pub use owned::OwnedFace; diff --git a/tests/glyph_outlines.rs b/tests/glyph_outlines.rs new file mode 100644 index 00000000..ace731e2 --- /dev/null +++ b/tests/glyph_outlines.rs @@ -0,0 +1,122 @@ +//! `Face::glyph_outlines` reads the outline tables once for a run of +//! glyphs. Every glyph must come out as `Face::glyph_outline_at_coords` +//! draws it, at the default instance and away from it, in any order. + +use sigilbuzz::tables::{OutlineSink, PathOp}; +use sigilbuzz::Face; + +const FONTS: &[(&str, &[u8])] = &[ + ( + "noto sans kr cff2", + include_bytes!("fixtures/noto_sans_kr_vf_cff2_subset.otf"), + ), + ( + "noto sans kr cff2 vertical", + include_bytes!("fixtures/noto_sans_kr_vf_vertical_subset.otf"), + ), + ( + "source sans 3 cff2", + include_bytes!("fonts/SourceSans3VF-Latin-Subset.otf"), + ), + ( + "source code pro cff", + include_bytes!("fonts/SourceCodePro-Latin-Subset.otf"), + ), + ( + "hahmlet glyf gvar", + include_bytes!("fixtures/hahmlet_gvar_subset.ttf"), + ), + ("rubik glyf gvar", include_bytes!("fixtures/rubik_vf.ttf")), + ( + "open sans glyf", + include_bytes!("fixtures/opensans_regular.ttf"), + ), +]; + +const SETTINGS: &[&[f32]] = &[&[], &[0.0], &[0.37], &[-0.61], &[1.0], &[0.5, 0.25]]; + +#[test] +fn a_run_draws_every_glyph_as_the_face_does() { + for &(name, data) in FONTS { + let face = Face::parse_bytes(data, 0).unwrap(); + let n = face.maxp().unwrap().num_glyphs; + for &coords in SETTINGS { + let outlines = face.glyph_outlines(coords); + // Backwards, then forwards, then past the last glyph: the + // kept tables must not depend on the order. + for gid in (0..n).rev().chain(0..n).chain([n, n + 7, u16::MAX]) { + assert_eq!( + outlines.outline(gid), + face.glyph_outline_at_coords(gid, coords), + "{name} glyph {gid} at {coords:?}" + ); + } + } + } +} + +/// Collects what a sink receives. +#[derive(Default)] +struct Ops(Vec); + +impl OutlineSink for Ops { + fn move_to(&mut self, x: f32, y: f32) { + self.0.push(PathOp::MoveTo { x, y }); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.push(PathOp::LineTo { x, y }); + } + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + self.0.push(PathOp::QuadTo { cx, cy, x, y }); + } + fn curve_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) { + self.0.push(PathOp::CubicTo { + c1x, + c1y, + c2x, + c2y, + x, + y, + }); + } + fn close(&mut self) { + self.0.push(PathOp::Close); + } +} + +#[test] +fn drawing_into_a_sink_sends_the_outline_ops() { + for &(name, data) in FONTS { + let face = Face::parse_bytes(data, 0).unwrap(); + let outlines = face.glyph_outlines(&[0.37]); + for gid in 0..face.maxp().unwrap().num_glyphs { + let mut ops = Ops::default(); + let drew = outlines.draw(gid, &mut ops).unwrap(); + let outline = outlines.outline(gid).unwrap(); + assert_eq!(drew, outline.is_some(), "{name} glyph {gid}"); + let want = outline.map(|o| o.ops().to_vec()).unwrap_or_default(); + assert_eq!(ops.0, want, "{name} glyph {gid}"); + } + } +} + +#[test] +fn a_broken_table_fails_every_glyph_alike() { + // A CFF2 table cut short: the face still parses, every glyph errors + // the way the face's own method errors. + let data = include_bytes!("fixtures/noto_sans_kr_vf_cff2_subset.otf"); + let face = Face::parse_bytes(data, 0).unwrap(); + let record = *face.record(*b"CFF2").unwrap(); + let mut broken = data.to_vec(); + let start = record.offset as usize; + for b in &mut broken[start..start + 8] { + *b = 0xFF; + } + let face = Face::parse_bytes(&broken, 0).unwrap(); + let outlines = face.glyph_outlines(&[0.5]); + for gid in 0..3 { + let want = face.glyph_outline_at_coords(gid, &[0.5]); + assert!(want.is_err()); + assert_eq!(outlines.outline(gid), want); + } +} From 481aa20e56b04c078dc9d5064c0a883bb197751e Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 10:40:30 -0800 Subject: [PATCH 08/15] Keep each merged stage plan per Font With the accelerators in place, merging a stage's lookups was the next per-call cost: for each feature of a stage, a query of the language system, a lookup list to collect, then a merge and a sort. The GPOS stage alone took about 2 us of a 7 us Segoe UI call, and the Indic shapers merge a dozen stages per run. A Font now keeps the merged lookups of each stage, keyed by the stage's kind and features (encoded as spec words), the caller's feature list, the script tags, the language, and the FeatureVariations record: the GPOS stage, the default and merged GSUB stages, and every stage of the syllabic shapers. A table keeps at most 64 plans, each with a key of at most 64 caller features and 256 spec words and at most 4096 lookups; anything larger is merged each time, as before. The stage merging code is unchanged and only runs when a plan is not kept. Also checks that every feature query through a kept language system answers as the walk does, over real fonts, scripts, languages and FeatureVariations records. --- src/ot/layout_select.rs | 366 ++++++++++++++++++++++++++++++++++++- src/ot/syllabic/stage.rs | 32 +++- src/shape/features.rs | 58 +++--- src/shape/gpos.rs | 19 ++ src/shape/gsub.rs | 27 +++ src/shape/position.rs | 24 ++- src/tables/gpos/mod.rs | 1 + src/tables/gsub/mod.rs | 1 + src/tables/layout/accel.rs | 9 +- 9 files changed, 504 insertions(+), 33 deletions(-) diff --git a/src/ot/layout_select.rs b/src/ot/layout_select.rs index a580b9cc..6d555b28 100644 --- a/src/ot/layout_select.rs +++ b/src/ot/layout_select.rs @@ -38,8 +38,9 @@ use alloc::borrow::Cow; use alloc::collections::BTreeMap; use alloc::vec::Vec; +use crate::shape::Feature; use crate::sync::OnceBox; -use crate::tables::layout::{ActiveFeatures, LangSys, ScriptList}; +use crate::tables::layout::{ActiveFeatures, Joiners, LangSys, ScriptList}; /// Script tags HarfBuzz falls back to, in order, when none of the /// run's own tags is in the table. @@ -225,9 +226,38 @@ pub(crate) struct LayoutView<'a> { pub(crate) features: ActiveFeatures<'a>, pub(crate) language_tags: &'a [[u8; 4]], pub(crate) maps: Option<&'a FeatureMaps>, + pub(crate) plans: Option<&'a StagePlans>, } -impl LayoutView<'_> { +impl<'a> LayoutView<'a> { + /// The lookups of one shaping stage, which `build` merges from the + /// features of the stage: the caller's `features`, and the + /// stage-specific `spec` (its own features, flags and settings, + /// encoded by the caller), for a run whose candidate script tags + /// are `script_priority`. The font's cache keeps the result, so a + /// stage is merged once per combination of those and of the view's + /// language and FeatureVariations record. Without a cache, or for a + /// stage too large to keep, `build` runs each time. + pub(crate) fn stage_plan( + &self, + script_priority: &[[u8; 4]], + features: &[Feature], + spec: &[u64], + build: impl Fn() -> Vec, + ) -> Cow<'a, [PlannedLookup]> { + let Some(plans) = self.plans else { + return Cow::Owned(build()); + }; + let key = StageKey { + script_priority, + language_tags: self.language_tags, + record: self.features.record(), + features, + spec, + }; + plans.get(&key, build) + } + /// [`feature_lookup_indices`] for this view. pub(crate) fn feature_lookups( &self, @@ -560,6 +590,182 @@ impl Default for FeatureMaps { } } +/// One lookup of a shaping stage, in a form every stage kind (the GSUB +/// stages, the syllabic shapers' stages, the GPOS stage) converts to +/// and from, so one cache keeps them all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PlannedLookup { + /// Index into the LookupList. + pub(crate) index: u16, + /// The alternate an AlternateSubst lookup picks. + pub(crate) alternate: u16, + /// The glyph mask bits the lookup applies at (syllabic stages). + pub(crate) mask: u32, + /// The joiner handling of the features that share the lookup. + pub(crate) joiners: Joiners, + /// The lookup matches within the cursor's syllable only. + pub(crate) per_syllable: bool, + /// The lookup applies only where the stage's mask says. + pub(crate) masked: bool, +} + +impl PlannedLookup { + /// A lookup with every setting at its default: automatic joiners, + /// no alternate, global. + pub(crate) const fn new(index: u16) -> Self { + Self { + index, + alternate: 0, + mask: u32::MAX, + joiners: Joiners::AUTO, + per_syllable: false, + masked: false, + } + } +} + +/// The kinds of stage a plan's spec starts with, so that two kinds of +/// stage over the same table never share a plan. +pub(crate) mod stage_kind { + /// The GPOS stage. + pub(crate) const GPOS: u64 = 1; + /// A GSUB stage of the default, Arabic and merged passes. + pub(crate) const GSUB: u64 = 2; + /// A GSUB stage of a syllabic shaper. + pub(crate) const SYLLABIC: u64 = 3; +} + +/// The spec word of joiner handling and two flags. +pub(crate) fn flag_bits(joiners: Joiners, first: bool, second: bool) -> u64 { + u64::from(joiners.auto_zwnj) + | u64::from(joiners.auto_zwj) << 1 + | u64::from(first) << 2 + | u64::from(second) << 3 +} + +/// Stage plans one table keeps at most. +const STAGE_SLOTS: usize = 64; +/// Caller features a kept plan's key may hold. +const MAX_STAGE_FEATURES: usize = 64; +/// Spec words a kept plan's key may hold. +const MAX_STAGE_SPEC: usize = 256; +/// Lookups a kept plan may hold. +const MAX_STAGE_LOOKUPS: usize = 4096; + +/// What a stage plan depends on. +struct StageKey<'k> { + script_priority: &'k [[u8; 4]], + language_tags: &'k [[u8; 4]], + record: Option, + features: &'k [Feature], + spec: &'k [u64], +} + +/// A kept stage plan with its key. +struct KeyedStage { + script_priority: Vec<[u8; 4]>, + language_tags: Vec<[u8; 4]>, + record: Option, + features: Vec, + spec: Vec, + lookups: Vec, +} + +impl KeyedStage { + fn matches(&self, key: &StageKey<'_>) -> bool { + self.spec == key.spec + && self.script_priority == key.script_priority + && self.record == key.record + && self.features == key.features + && self.language_tags == key.language_tags + } +} + +/// The stage plans one table has merged, kept by a [`crate::Font`] +/// across shaping calls: at most [`STAGE_SLOTS`] of them, each with a +/// key of at most [`MAX_STAGE_FEATURES`] caller features and +/// [`MAX_STAGE_SPEC`] spec words and at most [`MAX_STAGE_LOOKUPS`] +/// lookups. A stage past those limits is merged each time, as it is +/// without a cache. +pub(crate) struct StagePlans { + slots: [OnceBox; STAGE_SLOTS], +} + +impl StagePlans { + /// No stage merged yet. + pub(crate) fn new() -> Self { + Self { + slots: core::array::from_fn(|_| OnceBox::new()), + } + } + + /// The plan for `key`, merged with `build` the first time. + fn get<'s>( + &'s self, + key: &StageKey<'_>, + build: impl Fn() -> Vec, + ) -> Cow<'s, [PlannedLookup]> { + if key.features.len() > MAX_STAGE_FEATURES || key.spec.len() > MAX_STAGE_SPEC { + return Cow::Owned(build()); + } + let mut built: Option> = None; + for slot in &self.slots { + if let Some(kept) = slot.get() { + if kept.matches(key) { + return Cow::Borrowed(&kept.lookups); + } + continue; + } + let lookups = built.take().unwrap_or_else(&build); + if lookups.len() > MAX_STAGE_LOOKUPS { + return Cow::Owned(lookups); + } + let kept = slot.get_or_init(|| KeyedStage { + script_priority: key.script_priority.to_vec(), + language_tags: key.language_tags.to_vec(), + record: key.record, + features: key.features.to_vec(), + spec: key.spec.to_vec(), + lookups: lookups.clone(), + }); + if kept.matches(key) { + return Cow::Borrowed(&kept.lookups); + } + // Another thread kept a different stage here first. + built = Some(lookups); + } + Cow::Owned(built.unwrap_or_else(build)) + } + + /// Heap bytes the kept plans hold. + pub(crate) fn heap_bytes(&self) -> usize { + self.slots + .iter() + .filter_map(OnceBox::get) + .map(|k| { + core::mem::size_of::() + + (k.script_priority.capacity() + k.language_tags.capacity()) * 4 + + k.features.capacity() * core::mem::size_of::() + + k.spec.capacity() * 8 + + k.lookups.capacity() * core::mem::size_of::() + }) + .sum() + } +} + +impl Default for StagePlans { + fn default() -> Self { + Self::new() + } +} + +impl core::fmt::Debug for StagePlans { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let kept = self.slots.iter().filter(|s| s.get().is_some()).count(); + f.debug_struct("StagePlans").field("kept", &kept).finish() + } +} + impl core::fmt::Debug for FeatureMaps { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let kept = self.slots.iter().filter(|s| s.get().is_some()).count(); @@ -938,4 +1144,160 @@ mod tests { }; assert_eq!(f.lookups(&[], b"liga", &[*b"DFLT"]), None); } + + /// Every query through a kept [`FeatureMap`] answers as the walk + /// does, for the tags each real font lists and some it does not, + /// over several scripts, languages and FeatureVariations records. + #[test] + fn kept_language_systems_answer_as_the_walk_does() { + use crate::tables::layout::accel::LayoutCache; + use crate::tables::layout::LayoutTable; + use crate::Face; + let fonts: [&[u8]; 5] = [ + include_bytes!("../../tests/fixtures/opensans_regular.ttf"), + include_bytes!("../../tests/fixtures/amiri_regular.ttf"), + include_bytes!("../../tests/fonts/NotoSansDevanagari-Regular.ttf"), + include_bytes!("../../tests/fonts/NotoSansKR-Palt-Subset.ttf"), + include_bytes!("../../tests/fixtures/rubik_vf.ttf"), + ]; + let priorities: [&[[u8; 4]]; 6] = [ + &[*b"latn"], + &[*b"arab"], + &[*b"dev2", *b"deva"], + &[*b"hang"], + &[*b"cyrl"], + &[], + ]; + let languages: [&[[u8; 4]]; 4] = [&[], &[*b"TRK "], &[*b"URD ", *b"ARA "], &[*b"KOR "]]; + let unknown = [*b"zzzz", *b"vert", *b"kern", *b"liga", *b"rvrn", *b"mark"]; + for data in fonts { + let face = Face::parse_bytes(data, 0).unwrap(); + for gsub in [true, false] { + let (script_list, list, variations, count) = if gsub { + let Some(t) = face.gsub().unwrap() else { + continue; + }; + let v = t.feature_variations().ok().flatten(); + ( + *t.script_list(), + *t.feature_list(), + v, + t.lookup_list().len(), + ) + } else { + let Some(t) = face.gpos().unwrap() else { + continue; + }; + let v = t.feature_variations().ok().flatten(); + ( + *t.script_list(), + *t.feature_list(), + v, + t.lookup_list().len(), + ) + }; + let table = if gsub { + LayoutTable::Gsub + } else { + LayoutTable::Gpos + }; + let records: Vec, u32)>> = core::iter::once(None) + .chain( + variations + .into_iter() + .flat_map(|v| (0..v.len()).map(move |r| Some((v, r)))), + ) + .collect(); + let mut tags: Vec<[u8; 4]> = list.iter().map(|(tag, _)| tag).collect(); + tags.extend(unknown); + for record in records { + let features = ActiveFeatures::new(list, record); + let cache = LayoutCache::new(table, count); + for languages in languages { + let walk = LayoutView { + script_list, + features, + language_tags: languages, + maps: None, + plans: None, + }; + let kept = LayoutView { + maps: Some(&cache.maps), + ..walk + }; + for priority in priorities { + assert_eq!(kept.required(priority), walk.required(priority)); + for &tag in &tags { + let what = (tag, priority, languages, record.map(|r| r.1)); + assert_eq!( + kept.feature_lookups(tag, priority), + walk.feature_lookups(tag, priority), + "{what:?}" + ); + assert_eq!(kept.lists(tag, priority), walk.lists(tag, priority)); + assert_eq!( + kept.listed_lookups(tag, priority), + walk.listed_lookups(tag, priority), + "{what:?}" + ); + } + } + } + } + } + } + } + + const LATN: &[[u8; 4]] = &[*b"latn"]; + + #[test] + fn stage_plans_are_kept_per_key() { + let plans = StagePlans::new(); + let built = core::cell::Cell::new(0); + let build = |n: u16| { + built.set(built.get() + 1); + (0..n).map(PlannedLookup::new).collect::>() + }; + let liga = [Feature { + tag: *b"liga", + value: 0, + }]; + let key = |spec: &'static [u64], features: &'static [Feature]| StageKey { + script_priority: LATN, + language_tags: &[], + record: None, + features, + spec, + }; + let first = plans.get(&key(&[1, 0], &[]), || build(3)); + assert!(matches!(first, Cow::Borrowed(_))); + assert_eq!(first.len(), 3); + // The same key reads the kept plan. + let again = plans.get(&key(&[1, 0], &[]), || build(9)); + assert_eq!(again.len(), 3); + assert_eq!(built.get(), 1); + // Another spec or feature list is another plan. + assert_eq!(plans.get(&key(&[1, 1], &[]), || build(2)).len(), 2); + let with_liga: &'static [Feature] = Box::leak(Box::new(liga)); + assert_eq!(plans.get(&key(&[1, 0], with_liga), || build(4)).len(), 4); + assert_eq!(built.get(), 3); + // Keys and plans past the limits are merged each time. + let long: &'static [u64] = Box::leak(alloc::vec![7; MAX_STAGE_SPEC + 1].into_boxed_slice()); + assert!(matches!( + plans.get(&key(long, &[]), || build(1)), + Cow::Owned(_) + )); + let huge = plans.get(&key(&[5], &[]), || build(MAX_STAGE_LOOKUPS as u16 + 1)); + assert!(matches!(huge, Cow::Owned(_))); + // Once every slot holds another key, plans are not kept. + for i in 0..STAGE_SLOTS as u64 { + let spec: &'static [u64] = Box::leak(Box::new([100 + i])); + plans.get(&key(spec, &[]), || build(1)); + } + assert!(matches!( + plans.get(&key(&[999], &[]), || build(1)), + Cow::Owned(_) + )); + assert!(plans.heap_bytes() > 0); + } } diff --git a/src/ot/syllabic/stage.rs b/src/ot/syllabic/stage.rs index 1896a152..ea60cd99 100644 --- a/src/ot/syllabic/stage.rs +++ b/src/ot/syllabic/stage.rs @@ -22,6 +22,7 @@ use alloc::vec::Vec; use super::GlyphInfo; use crate::buffer::Glyph; +use crate::ot::layout_select::{stage_kind, PlannedLookup}; use crate::shape::{Feature, SyllabicGsub}; use crate::tables::layout::skip_iter::match_prop; use crate::tables::layout::Joiners; @@ -315,10 +316,37 @@ pub(crate) fn apply_stage( if glyphs.len() != info.len() || glyphs.is_empty() || glyphs.len() > MAX_SLOTS { return; } - let lookups = stage_lookups(runner, script_priority, features, user); - if lookups.is_empty() { + // The stage's lookups, merged once per script, language, feature + // list and stage and kept by the font. + let spec: Vec = core::iter::once(stage_kind::SYLLABIC) + .chain(features.iter().flat_map(|f| { + [ + u64::from(u32::from_be_bytes(f.tag)) << 8 | u64::from(f.flags.0), + u64::from(f.mask), + ] + })) + .collect(); + let view = runner.gsub().layout_view(); + let planned = view.stage_plan(script_priority, user, &spec, || { + stage_lookups(runner, script_priority, features, user) + .into_iter() + .map(|l| PlannedLookup { + mask: l.mask, + joiners: l.joiners, + per_syllable: l.per_syllable, + ..PlannedLookup::new(l.index) + }) + .collect() + }); + if planned.is_empty() { return; } + let lookups = planned.iter().map(|p| StageLookup { + index: p.index, + mask: p.mask, + joiners: p.joiners, + per_syllable: p.per_syllable, + }); let mut slots: Vec = glyphs .iter_mut() diff --git a/src/shape/features.rs b/src/shape/features.rs index be79ed5f..08effe02 100644 --- a/src/shape/features.rs +++ b/src/shape/features.rs @@ -13,6 +13,7 @@ use super::gsub::{ use super::joiners::FeatureFlags; use super::{feature_disabled, feature_enabled, Feature, JoinerTable, LookupBudget}; use crate::buffer::Glyph; +use crate::ot::layout_select::{flag_bits, stage_kind}; use crate::tables::gdef::Gdef; use crate::tables::Gsub; @@ -170,29 +171,42 @@ fn apply_feature_stage( mask: Option<&[bool]>, budget: &mut LookupBudget, ) { - let mut lookups: BTreeMap = BTreeMap::new(); - for f in stage { - if feature_disabled(features, f.tag) { - continue; - } - for index in - lookup_indices_for_feature_in_scripts(gsub, f.tag, script_priority).unwrap_or_default() - { - lookups - .entry(index) - .and_modify(|l| { - l.flags = l.flags.and(f.flags); - l.masked &= f.masked; - }) - .or_insert(StageLookup { - index, - flags: f.flags, - alternate: f.alternate, - masked: f.masked, - }); + // The stage's lookups, merged once per script, language, feature + // list and stage and kept by the font. + let spec: Vec = core::iter::once(stage_kind::GSUB) + .chain(stage.iter().map(|f| { + u64::from(u32::from_be_bytes(f.tag)) << 32 + | u64::from(f.alternate) << 16 + | flag_bits(f.flags.joiners, f.flags.per_syllable, f.masked) + })) + .collect(); + let view = gsub.layout_view(); + let planned = view.stage_plan(script_priority, features, &spec, || { + let mut lookups: BTreeMap = BTreeMap::new(); + for f in stage { + if feature_disabled(features, f.tag) { + continue; + } + for index in lookup_indices_for_feature_in_scripts(gsub, f.tag, script_priority) + .unwrap_or_default() + { + lookups + .entry(index) + .and_modify(|l| { + l.flags = l.flags.and(f.flags); + l.masked &= f.masked; + }) + .or_insert(StageLookup { + index, + flags: f.flags, + alternate: f.alternate, + masked: f.masked, + }); + } } - } - let lookups: Vec = lookups.into_values().collect(); + lookups.into_values().map(StageLookup::planned).collect() + }); + let lookups: Vec = planned.iter().map(StageLookup::from_planned).collect(); apply_gsub_stage(gsub, &lookups, glyphs, gdef, mask, budget); } diff --git a/src/shape/gpos.rs b/src/shape/gpos.rs index 28707386..03308705 100644 --- a/src/shape/gpos.rs +++ b/src/shape/gpos.rs @@ -38,6 +38,7 @@ use super::{ MAX_NESTED_DEPTH, }; use crate::buffer::Glyph; +use crate::ot::layout_select::PlannedLookup; use crate::tables::gdef::Gdef; use crate::tables::gpos::{ lookup_type as gpos_lt, ChainContextPos, ContextPos, PairPos, SinglePos, ValueRecord, @@ -114,6 +115,24 @@ pub(super) fn stage_lookups( out } +impl StageLookup { + /// The lookup as a stage plan keeps it. + pub(super) fn planned(self) -> PlannedLookup { + PlannedLookup { + joiners: self.joiners, + ..PlannedLookup::new(self.index) + } + } + + /// The lookup a stage plan kept. + pub(super) fn from_planned(p: &PlannedLookup) -> Self { + Self { + index: p.index, + joiners: p.joiners, + } + } +} + /// Shared inputs of every lookup in the stage. pub(super) struct GposCx<'a> { pub(super) gpos: &'a Gpos<'a>, diff --git a/src/shape/gsub.rs b/src/shape/gsub.rs index 88789ef3..54d93b8a 100644 --- a/src/shape/gsub.rs +++ b/src/shape/gsub.rs @@ -26,6 +26,7 @@ use super::gsub_parsed::{ use super::joiners::FeatureFlags; use super::{resolve_extension, LookupBudget, MAX_NESTED_DEPTH}; use crate::buffer::{unicode_prop, Glyph}; +use crate::ot::layout_select::PlannedLookup; use crate::tables::gdef::Gdef; use crate::tables::gsub::{lookup_type as gsub_lt, ChainContextAny, Context as GsubContext}; use crate::tables::layout::accel::Accel; @@ -132,6 +133,32 @@ pub(super) struct StageLookup { pub(super) masked: bool, } +impl StageLookup { + /// The lookup as a stage plan keeps it. + pub(super) fn planned(self) -> PlannedLookup { + PlannedLookup { + alternate: self.alternate, + joiners: self.flags.joiners, + per_syllable: self.flags.per_syllable, + masked: self.masked, + ..PlannedLookup::new(self.index) + } + } + + /// The lookup a stage plan kept. + pub(super) fn from_planned(p: &PlannedLookup) -> Self { + Self { + index: p.index, + flags: FeatureFlags { + joiners: p.joiners, + per_syllable: p.per_syllable, + }, + alternate: p.alternate, + masked: p.masked, + } + } +} + /// Applies the lookups of one GSUB stage in order, HarfBuzz's /// `hb_ot_map_t::apply` over a stage: each lookup once, the masked ones /// only on the glyphs `mask` marks. The mask moves with its glyphs diff --git a/src/shape/position.rs b/src/shape/position.rs index 8c3d7b96..71949738 100644 --- a/src/shape/position.rs +++ b/src/shape/position.rs @@ -40,6 +40,7 @@ use crate::buffer::{Direction, Glyph}; use crate::error::Result; use crate::face::Face; use crate::font::{GlyphValue, InstanceCache, Known}; +use crate::ot::layout_select::stage_kind; use crate::tables::gdef::Gdef; use crate::tables::glyf::PhantomMetrics; use crate::tables::layout::{GlyphClasses, MatchGlyph}; @@ -197,10 +198,25 @@ pub(super) fn position( if seg.range.is_empty() { continue; } - let required = required_lookups(gpos, seg.script_priority); - let lookups = gpos::stage_lookups(input.features, horizontal, &required, |tag| { - lookups_for(gpos, tag, seg.script_priority) - }); + // The stage's lookups, merged once per script, language, + // direction and feature list and kept by the font. + let spec = [stage_kind::GPOS, u64::from(horizontal)]; + let prio = seg.script_priority; + let planned = gpos + .layout_view() + .stage_plan(prio, input.features, &spec, || { + let required = required_lookups(gpos, prio); + gpos::stage_lookups(input.features, horizontal, &required, |tag| { + lookups_for(gpos, tag, prio) + }) + .into_iter() + .map(gpos::StageLookup::planned) + .collect() + }); + let lookups: Vec = planned + .iter() + .map(gpos::StageLookup::from_planned) + .collect(); let (Some(seg_glyphs), Some(seg_slots)) = ( glyphs.get_mut(seg.range.clone()), slots.get_mut(seg.range.clone()), diff --git a/src/tables/gpos/mod.rs b/src/tables/gpos/mod.rs index 5d55a990..cc6714da 100644 --- a/src/tables/gpos/mod.rs +++ b/src/tables/gpos/mod.rs @@ -230,6 +230,7 @@ impl<'a> Gpos<'a> { features: self.features(), language_tags: self.language_tags, maps: self.cache.map(|c| &c.maps), + plans: self.cache.map(|c| &c.plans), } } diff --git a/src/tables/gsub/mod.rs b/src/tables/gsub/mod.rs index 202cdebb..db452a7d 100644 --- a/src/tables/gsub/mod.rs +++ b/src/tables/gsub/mod.rs @@ -236,6 +236,7 @@ impl<'a> Gsub<'a> { features: self.features(), language_tags: self.language_tags, maps: self.cache.map(|c| &c.maps), + plans: self.cache.map(|c| &c.plans), } } diff --git a/src/tables/layout/accel.rs b/src/tables/layout/accel.rs index 1b6c7da6..58d2aae4 100644 --- a/src/tables/layout/accel.rs +++ b/src/tables/layout/accel.rs @@ -30,7 +30,7 @@ use alloc::vec::Vec; use super::coverage::Coverage; use super::lookup_list::Lookup; use super::skip_iter::LayoutTable; -use crate::ot::layout_select::FeatureMaps; +use crate::ot::layout_select::{FeatureMaps, StagePlans}; use crate::sync::OnceBox; /// Glyph-id bits each of the digest's three masks hashes on. @@ -358,11 +358,13 @@ impl core::fmt::Debug for LookupAccels { } /// What a [`crate::Font`] keeps for one GSUB or GPOS table: the lookup -/// accelerators and the resolved language systems. +/// accelerators, the resolved language systems, and the merged stage +/// plans. #[derive(Debug)] pub(crate) struct LayoutCache { pub(crate) accels: LookupAccels, pub(crate) maps: FeatureMaps, + pub(crate) plans: StagePlans, } impl LayoutCache { @@ -371,12 +373,13 @@ impl LayoutCache { Self { accels: LookupAccels::new(table, lookup_count), maps: FeatureMaps::new(), + plans: StagePlans::new(), } } /// Heap bytes the cache holds. pub(crate) fn heap_bytes(&self) -> usize { - self.accels.heap_bytes() + self.maps.heap_bytes() + self.accels.heap_bytes() + self.maps.heap_bytes() + self.plans.heap_bytes() } } From 1078bd27ea860b6c7f180a8683640ccf11c8621a Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 10:51:14 -0800 Subject: [PATCH 09/15] Compile the syllable machines once The Indic, Khmer, Myanmar and USE shapers built their syllable grammar from patterns and compiled it to an NFA on every call, about 25 us of a 40 us one-character Devanagari call. The grammars are constants, so each is now compiled the first time a run needs it and kept in a static OnceBox. --- src/ot/indic/machine.rs | 11 +++++++++-- src/ot/khmer/machine.rs | 19 ++++++++++++------- src/ot/myanmar/machine.rs | 21 +++++++++++++-------- src/ot/use_shaper/machine.rs | 9 ++++++++- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/ot/indic/machine.rs b/src/ot/indic/machine.rs index 26d5aff1..c81c7513 100644 --- a/src/ot/indic/machine.rs +++ b/src/ot/indic/machine.rs @@ -33,6 +33,7 @@ use crate::ot::syllabic::cat::{ VD, ZWJ, ZWNJ, }; use crate::ot::syllabic::machine::{alt, one, opt, seq, star, Machine, Pat, Syllable}; +use crate::sync::OnceBox; /// Indic syllable types (`indic_syllable_type_t`). pub(crate) mod syllable { @@ -142,8 +143,14 @@ fn broken_cluster() -> Pat { seq([opt(reph()), opt(n()), complex_syllable_tail()]) } -/// The compiled Indic grammar. -pub(super) fn machine() -> Machine { +/// The compiled Indic grammar, compiled once. +pub(super) fn machine() -> &'static Machine { + static MACHINE: OnceBox = OnceBox::new(); + MACHINE.get_or_init(compile) +} + +/// Compiles the Indic grammar. +fn compile() -> Machine { Machine::new( vec![ (consonant_syllable(), syllable::CONSONANT), diff --git a/src/ot/khmer/machine.rs b/src/ot/khmer/machine.rs index 7003f77b..1e6ed7d1 100644 --- a/src/ot/khmer/machine.rs +++ b/src/ot/khmer/machine.rs @@ -23,6 +23,7 @@ use crate::ot::syllabic::cat::{ ZWNJ, }; use crate::ot::syllabic::machine::{alt, one, opt, seq, star, Machine, Pat, Syllable}; +use crate::sync::OnceBox; fn c() -> Pat { one(&[C, RA, V]) @@ -78,14 +79,18 @@ fn consonant_syllable() -> Pat { } /// Splits Khmer categories into syllables (`find_syllables_khmer`). +/// The grammar is compiled once. pub(super) fn find_syllables(cats: &[u8]) -> Vec { - let machine = Machine::new( - vec![ - (consonant_syllable(), syllable::CONSONANT), - (broken_cluster(), syllable::BROKEN), - ], - syllable::NON_KHMER, - ); + static MACHINE: OnceBox = OnceBox::new(); + let machine = MACHINE.get_or_init(|| { + Machine::new( + vec![ + (consonant_syllable(), syllable::CONSONANT), + (broken_cluster(), syllable::BROKEN), + ], + syllable::NON_KHMER, + ) + }); machine.scan(cats) } diff --git a/src/ot/myanmar/machine.rs b/src/ot/myanmar/machine.rs index f32fa031..159deb84 100644 --- a/src/ot/myanmar/machine.rs +++ b/src/ot/myanmar/machine.rs @@ -33,6 +33,7 @@ use crate::ot::syllabic::cat::{ VBLW, VPRE, VPST, VS, ZWJ, ZWNJ, }; use crate::ot::syllabic::machine::{alt, one, opt, seq, star, Machine, Pat, Syllable}; +use crate::sync::OnceBox; fn j() -> Pat { one(&[ZWJ, ZWNJ]) @@ -124,15 +125,19 @@ fn broken_cluster() -> Pat { } /// Splits Myanmar categories into syllables (`find_syllables_myanmar`). +/// The grammar is compiled once. pub(super) fn find_syllables(cats: &[u8]) -> Vec { - let machine = Machine::new( - vec![ - (consonant_syllable(), syllable::CONSONANT), - (one(&[ZWJ, ZWNJ, SMPST]), syllable::NON_MYANMAR), - (broken_cluster(), syllable::BROKEN), - ], - syllable::NON_MYANMAR, - ); + static MACHINE: OnceBox = OnceBox::new(); + let machine = MACHINE.get_or_init(|| { + Machine::new( + vec![ + (consonant_syllable(), syllable::CONSONANT), + (one(&[ZWJ, ZWNJ, SMPST]), syllable::NON_MYANMAR), + (broken_cluster(), syllable::BROKEN), + ], + syllable::NON_MYANMAR, + ) + }); machine.scan(cats) } diff --git a/src/ot/use_shaper/machine.rs b/src/ot/use_shaper/machine.rs index 707ad8f2..bd6ea6a2 100644 --- a/src/ot/use_shaper/machine.rs +++ b/src/ot/use_shaper/machine.rs @@ -40,6 +40,7 @@ use super::category::{ }; use crate::ot::syllabic::machine::{alt, one, opt, seq, star, Machine, Pat}; use crate::ot::syllabic::GlyphInfo; +use crate::sync::OnceBox; /// USE syllable types (`use_syllable_type_t`). pub(crate) mod syllable { @@ -208,8 +209,14 @@ fn with_zwnj(rule: Pat) -> Pat { seq([rule, opt(one(&[ZWNJ]))]) } +/// The machine, compiled once. +fn machine() -> &'static Machine { + static MACHINE: OnceBox = OnceBox::new(); + MACHINE.get_or_init(compile) +} + /// The machine's rules, in priority order. -fn machine() -> Machine { +fn compile() -> Machine { use syllable::{ BROKEN, HIEROGLYPH, NON_CLUSTER, NUMBER_JOINER_TERMINATED, NUMERAL, SAKOT_TERMINATED, STANDARD, SYMBOL, VIRAMA_TERMINATED, From 404295a40470bf5aced70c1e03ab2f4ae17d7060 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 10:51:14 -0800 Subject: [PATCH 10/15] Skip syllabic stages and dry runs no lookup can start A syllabic stage built its glyph slots before looking at its lookups, and the Indic consonant-position test set up a scratch run for every dry run of a feature. When no lookup of the stage or feature can start at a glyph of the run, both leave the run as it is, so they now return early: the stage only sets the syllables its slots would have set. Stage plans also start probing at a slot their key hashes to, so the dozen stages of an Indic run each find their plan at the first slot. --- src/ot/layout_select.rs | 26 +++++++++++++++++++++++++- src/ot/syllabic/stage.rs | 9 +++++++++ src/shape/features.rs | 13 ++++++++++++- src/shape/gsub.rs | 8 ++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/ot/layout_select.rs b/src/ot/layout_select.rs index 6d555b28..f42a771d 100644 --- a/src/ot/layout_select.rs +++ b/src/ot/layout_select.rs @@ -661,6 +661,26 @@ struct StageKey<'k> { spec: &'k [u64], } +impl StageKey<'_> { + /// An FNV-1a hash of the key's spec, script tags and feature tags, + /// which picks the first slot a lookup probes. + fn slot_hash(&self) -> usize { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut mix = |w: u64| { + h ^= w; + h = h.wrapping_mul(0x0100_0000_01b3); + }; + self.spec.iter().for_each(|&w| mix(w)); + self.script_priority + .iter() + .for_each(|t| mix(u64::from(u32::from_be_bytes(*t)))); + self.features + .iter() + .for_each(|f| mix(u64::from(u32::from_be_bytes(f.tag)) << 32 | u64::from(f.value))); + (h >> 32) as usize + } +} + /// A kept stage plan with its key. struct KeyedStage { script_priority: Vec<[u8; 4]>, @@ -709,7 +729,11 @@ impl StagePlans { return Cow::Owned(build()); } let mut built: Option> = None; - for slot in &self.slots { + // Probing starts at a slot the key hashes to, so a stage usually + // finds its plan at the first slot it looks at. + let start = key.slot_hash() % STAGE_SLOTS; + let order = (start..STAGE_SLOTS).chain(0..start); + for slot in order.filter_map(|i| self.slots.get(i)) { if let Some(kept) = slot.get() { if kept.matches(key) { return Cow::Borrowed(&kept.lookups); diff --git a/src/ot/syllabic/stage.rs b/src/ot/syllabic/stage.rs index ea60cd99..62783000 100644 --- a/src/ot/syllabic/stage.rs +++ b/src/ot/syllabic/stage.rs @@ -341,6 +341,15 @@ pub(crate) fn apply_stage( if planned.is_empty() { return; } + // A stage none of whose lookups can start at a glyph of the run + // changes nothing but the syllables the glyphs carry, which the + // slots below would set. + if !planned.iter().any(|p| runner.may_apply(p.index, glyphs)) { + for (g, info) in glyphs.iter_mut().zip(info.iter()) { + g.syllable = info.syllable; + } + return; + } let lookups = planned.iter().map(|p| StageLookup { index: p.index, mask: p.mask, diff --git a/src/shape/features.rs b/src/shape/features.rs index 08effe02..576a85e5 100644 --- a/src/shape/features.rs +++ b/src/shape/features.rs @@ -8,7 +8,7 @@ use alloc::vec::Vec; use super::arabic_joining::Action; use super::gsub::{ apply_gsub_lookup, apply_gsub_lookups_masked, apply_gsub_stage, gsub_lookup_may_apply, - StageLookup, + gsub_lookup_may_apply_to_ids, StageLookup, }; use super::joiners::FeatureFlags; use super::{feature_disabled, feature_enabled, Feature, JoinerTable, LookupBudget}; @@ -503,6 +503,17 @@ pub(crate) fn feature_would_substitute( if glyph_ids.is_empty() { return false; } + // Lookups none of which can start at one of the glyphs change + // nothing, so the dry run is not worth setting up. + let may_apply = + lookup_indices_for_feature_in_scripts(gsub, tag, script_priority).is_some_and(|lookups| { + lookups + .iter() + .any(|&index| gsub_lookup_may_apply_to_ids(gsub, index, glyph_ids)) + }); + if !may_apply { + return false; + } // Build a throw-away glyph slice: cluster values don't matter, // only glyph ids survive the dry run. Start clusters at 0 so a // ligature merge collapses them to 0 deterministically. diff --git a/src/shape/gsub.rs b/src/shape/gsub.rs index 54d93b8a..69ecac31 100644 --- a/src/shape/gsub.rs +++ b/src/shape/gsub.rs @@ -250,6 +250,14 @@ pub(crate) fn gsub_lookup_may_apply(gsub: &Gsub<'_>, index: u16, glyphs: &[Glyph .is_some_and(|lookup| lookup_may_apply(&gsub.lookup_accel(index, &lookup), glyphs)) } +/// [`gsub_lookup_may_apply`] over bare glyph ids. +pub(crate) fn gsub_lookup_may_apply_to_ids(gsub: &Gsub<'_>, index: u16, ids: &[u16]) -> bool { + gsub.lookup_list().get(index).is_some_and(|lookup| { + gsub.lookup_accel(index, &lookup) + .may_apply(ids.iter().copied()) + }) +} + /// One pass of `lookup`, lookup `index` of the table, over `buf`, /// HarfBuzz's `apply_string`: `apply_forward`, whose cursor stops at /// each glyph the feature is on at, the lookup flags keep, and the From d83cfa41a66d4a1e65a59fccfba902ce76f0fc95 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 10:51:33 -0800 Subject: [PATCH 11/15] Describe the stage plans in the Font cache docs --- src/font/cache.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/font/cache.rs b/src/font/cache.rs index e510dc99..a31d0d4b 100644 --- a/src/font/cache.rs +++ b/src/font/cache.rs @@ -6,10 +6,10 @@ //! kinds of data: //! //! - [`FaceCache`], which depends on the font data alone: for GSUB and -//! GPOS, the lookup accelerators (see [`crate::tables::layout::accel`]) -//! and the language systems resolved for each combination of script -//! tags, language and FeatureVariations record a run used (see -//! [`crate::ot::layout_select`]). +//! GPOS, the lookup accelerators (see [`crate::tables::layout::accel`]), +//! the language systems resolved for each combination of script tags, +//! language and FeatureVariations record a run used, and the merged +//! lookups of each shaping stage (see [`crate::ot::layout_select`]). //! [`Font::with_coords`](super::Font::with_coords) and //! [`Font::with_size`](super::Font::with_size) keep it. //! - [`InstanceCache`], which depends on the variation coordinates too: @@ -31,11 +31,11 @@ //! output does not depend on what is cached. //! //! Memory is bounded per font. For each of GSUB and GPOS: one pointer -//! per lookup, 24 bytes per subtable of each lookup a run reached, and -//! at most 16 resolved language systems of at most 1024 features and -//! 16384 lookup indices each. For the glyph caches: at most -//! [`GLYPH_CACHE_MAX`] four-byte entries for each of the three, -//! whatever the glyph count. +//! per lookup, 24 bytes per subtable of each lookup a run reached, at +//! most 16 resolved language systems of at most 1024 features and 16384 +//! lookup indices each, and at most 64 stage plans of at most 4096 +//! lookups each. For the glyph caches: at most [`GLYPH_CACHE_MAX`] +//! four-byte entries for each of the three, whatever the glyph count. use alloc::boxed::Box; use alloc::sync::Arc; From 8c5a9d73979470bded5f937a16fa5cd9570968e6 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:08:56 -0800 Subject: [PATCH 12/15] Check lookups ahead of setup only with the font's digests The checks ahead of a lookup (in syllabic stages, masked features, dry runs and single lookups) are cheap with the font's digests, but in a font's first call they read every subtable's coverage, and the lookup then reads them again. A view without the font's cache now skips them and lets the lookup check itself, as 0.23.1 did, which keeps one-shot fonts with long runs from paying twice. --- src/shape/gsub.rs | 19 +++++++++++++------ src/tables/gsub/mod.rs | 6 ++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/shape/gsub.rs b/src/shape/gsub.rs index 69ecac31..19d2f77e 100644 --- a/src/shape/gsub.rs +++ b/src/shape/gsub.rs @@ -220,7 +220,7 @@ pub(super) fn apply_gsub_lookup( let Some(lookup) = gsub.lookup_list().get(lookup_idx) else { return; }; - if !lookup_may_apply(&gsub.lookup_accel(lookup_idx, &lookup), glyphs) { + if gsub.has_cache() && !lookup_may_apply(&gsub.lookup_accel(lookup_idx, &lookup), glyphs) { return; } let cx = GsubCx { gsub, gdef, flags }; @@ -244,17 +244,24 @@ pub(super) fn lookup_may_apply(accel: &Accel<'_, '_>, glyphs: &[Glyph]) -> bool /// True when GSUB lookup `index` may change `glyphs` (see /// [`lookup_may_apply`]); false for a lookup the table does not have. +/// +/// This is a check ahead of setting a lookup up, and only pays off with +/// the font's digests: without them (a font's first call) it answers +/// true and leaves the check to the lookup itself, which would +/// otherwise search every subtable's coverage twice. pub(crate) fn gsub_lookup_may_apply(gsub: &Gsub<'_>, index: u16, glyphs: &[Glyph]) -> bool { - gsub.lookup_list() - .get(index) - .is_some_and(|lookup| lookup_may_apply(&gsub.lookup_accel(index, &lookup), glyphs)) + gsub.lookup_list().get(index).is_some_and(|lookup| { + !gsub.has_cache() || lookup_may_apply(&gsub.lookup_accel(index, &lookup), glyphs) + }) } /// [`gsub_lookup_may_apply`] over bare glyph ids. pub(crate) fn gsub_lookup_may_apply_to_ids(gsub: &Gsub<'_>, index: u16, ids: &[u16]) -> bool { gsub.lookup_list().get(index).is_some_and(|lookup| { - gsub.lookup_accel(index, &lookup) - .may_apply(ids.iter().copied()) + !gsub.has_cache() + || gsub + .lookup_accel(index, &lookup) + .may_apply(ids.iter().copied()) }) } diff --git a/src/tables/gsub/mod.rs b/src/tables/gsub/mod.rs index db452a7d..a98f922c 100644 --- a/src/tables/gsub/mod.rs +++ b/src/tables/gsub/mod.rs @@ -228,6 +228,12 @@ impl<'a> Gsub<'a> { self } + /// True when the view has the font's cache, so its lookup + /// accelerators are digests rather than coverages read per use. + pub(crate) const fn has_cache(&self) -> bool { + self.cache.is_some() + } + /// What feature resolution (`crate::ot::layout_select`) reads of /// this view. pub(crate) fn layout_view(&self) -> LayoutView<'a> { From b35ac64caa8b166a2316b16c6ddea8ff64a44272 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:08:56 -0800 Subject: [PATCH 13/15] Inline the coverage lookups always Coverage::index_of and contains run once or twice per glyph per subtable in every GSUB and GPOS walk. The lookup accelerators added callers, after which the inliner stopped inlining them into the PairPos path, and a kerned 100-glyph Roboto run took about 15% longer in GPOS than on main with the same GPOS code. Inlining them always restores it. --- src/tables/layout/coverage.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tables/layout/coverage.rs b/src/tables/layout/coverage.rs index 7cc06c91..13e0b888 100644 --- a/src/tables/layout/coverage.rs +++ b/src/tables/layout/coverage.rs @@ -60,6 +60,12 @@ enum Format { Format2, } +// The coverage lookups are the innermost step of every GSUB and GPOS +// walk, called once or twice per glyph per subtable. Left to the +// inliner they stopped being inlined into the PairPos path once the +// lookup accelerators added callers, which cost a kerned run about 15%, +// so they are inlined always. +#[allow(clippy::inline_always)] impl<'a> Coverage<'a> { /// Parses a Coverage table from its raw bytes. pub fn parse(data: &'a [u8]) -> Result { @@ -114,6 +120,7 @@ impl<'a> Coverage<'a> { /// Coverage index of `glyph_id`, or `None` if the glyph is not /// covered by this table. #[must_use] + #[inline(always)] pub fn index_of(&self, glyph_id: u16) -> Option { match self.format { Format::Format1 => self.index_format1(glyph_id), @@ -123,6 +130,7 @@ impl<'a> Coverage<'a> { /// True if the table covers `glyph_id`. #[must_use] + #[inline(always)] pub fn contains(&self, glyph_id: u16) -> bool { self.index_of(glyph_id).is_some() } @@ -145,11 +153,13 @@ impl<'a> Coverage<'a> { } } + #[inline(always)] fn glyph_at(&self, i: u16) -> u16 { let off = self.body_off + i as usize * 2; u16::from_be_bytes([self.data[off], self.data[off + 1]]) } + #[inline(always)] fn range_at(&self, i: u16) -> (u16, u16, u16) { let off = self.body_off + i as usize * 6; let start = u16::from_be_bytes([self.data[off], self.data[off + 1]]); @@ -158,6 +168,7 @@ impl<'a> Coverage<'a> { (start, end, start_cov) } + #[inline(always)] fn index_format1(&self, glyph_id: u16) -> Option { if self.count == 0 { return None; @@ -176,6 +187,7 @@ impl<'a> Coverage<'a> { None } + #[inline(always)] fn index_format2(&self, glyph_id: u16) -> Option { if self.count == 0 { return None; From 16c36a8843f63e72646d52745966254778e9fc77 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:32:39 -0800 Subject: [PATCH 14/15] Bound digest building per table as well as per lookup Each lookup's accelerator reads at most max(16384, 64 per subtable) coverage entries, but a hostile font can point every one of thousands of lookups at one huge coverage and make building them all read it again and again. The accelerators of a table now also share a budget of twice the table's length in entries (plus 64 Ki), spent with a compare-and-swap so threads cannot overdraw it. Past it, a subtable's digest admits every glyph, as past the per-lookup budget, so the work stays linear in the size of the font. --- src/font/cache.rs | 12 ++-- src/ot/layout_select.rs | 2 +- src/shape/pipeline.rs | 4 +- src/tables/gpos/mod.rs | 5 ++ src/tables/gsub/mod.rs | 5 ++ src/tables/layout/accel.rs | 110 +++++++++++++++++++++++++++++-------- 6 files changed, 107 insertions(+), 31 deletions(-) diff --git a/src/font/cache.rs b/src/font/cache.rs index a31d0d4b..fa4a2b6a 100644 --- a/src/font/cache.rs +++ b/src/font/cache.rs @@ -149,17 +149,17 @@ impl FaceCache { } /// What the font keeps for its GSUB, which has `lookup_count` - /// lookups. - pub(crate) fn gsub(&self, lookup_count: u16) -> &LayoutCache { + /// lookups in `table_len` bytes. + pub(crate) fn gsub(&self, lookup_count: u16, table_len: usize) -> &LayoutCache { self.gsub - .get_or_init(|| LayoutCache::new(LayoutTable::Gsub, lookup_count)) + .get_or_init(|| LayoutCache::new(LayoutTable::Gsub, lookup_count, table_len)) } /// What the font keeps for its GPOS, which has `lookup_count` - /// lookups. - pub(crate) fn gpos(&self, lookup_count: u16) -> &LayoutCache { + /// lookups in `table_len` bytes. + pub(crate) fn gpos(&self, lookup_count: u16, table_len: usize) -> &LayoutCache { self.gpos - .get_or_init(|| LayoutCache::new(LayoutTable::Gpos, lookup_count)) + .get_or_init(|| LayoutCache::new(LayoutTable::Gpos, lookup_count, table_len)) } fn heap_bytes(&self) -> usize { diff --git a/src/ot/layout_select.rs b/src/ot/layout_select.rs index f42a771d..aa6f5f07 100644 --- a/src/ot/layout_select.rs +++ b/src/ot/layout_select.rs @@ -1236,7 +1236,7 @@ mod tests { tags.extend(unknown); for record in records { let features = ActiveFeatures::new(list, record); - let cache = LayoutCache::new(table, count); + let cache = LayoutCache::new(table, count, 1 << 20); for languages in languages { let walk = LayoutView { script_list, diff --git a/src/shape/pipeline.rs b/src/shape/pipeline.rs index 96967bb3..739cf57a 100644 --- a/src/shape/pipeline.rs +++ b/src/shape/pipeline.rs @@ -259,7 +259,7 @@ pub fn shape(font: &Font<'_>, buffer: &Buffer, features: &[Feature]) -> Result, buffer: &Buffer, features: &[Feature]) -> Result Gpos<'a> { self } + /// The table's length in bytes. + pub(crate) const fn table_len(&self) -> usize { + self.data.len() + } + /// What feature resolution (`crate::ot::layout_select`) reads of /// this view. pub(crate) fn layout_view(&self) -> LayoutView<'a> { diff --git a/src/tables/gsub/mod.rs b/src/tables/gsub/mod.rs index a98f922c..c7d274b1 100644 --- a/src/tables/gsub/mod.rs +++ b/src/tables/gsub/mod.rs @@ -234,6 +234,11 @@ impl<'a> Gsub<'a> { self.cache.is_some() } + /// The table's length in bytes. + pub(crate) const fn table_len(&self) -> usize { + self.data.len() + } + /// What feature resolution (`crate::ot::layout_select`) reads of /// this view. pub(crate) fn layout_view(&self) -> LayoutView<'a> { diff --git a/src/tables/layout/accel.rs b/src/tables/layout/accel.rs index 58d2aae4..a8938aee 100644 --- a/src/tables/layout/accel.rs +++ b/src/tables/layout/accel.rs @@ -19,13 +19,16 @@ //! view without the font's cache, a lookup reads its subtables' primary //! coverages directly instead ([`DirectAccel`]): a font shaped once, //! which many callers build per run, would spend more building digests -//! than they save. Building reads each subtable's primary coverage once, with a -//! work budget per lookup: past it, a subtable gets a digest that admits -//! every glyph, so a hostile font that points thousands of subtables at -//! one huge coverage costs no more than the subtables themselves. +//! than they save. Building reads each subtable's primary coverage once, +//! under two work budgets: one per lookup, and one per table +//! proportional to the table's size. Past either, a subtable gets a +//! digest that admits every glyph, so a hostile font that points +//! thousands of subtables or lookups at one huge coverage costs no more +//! than reading its table a few times. use alloc::boxed::Box; use alloc::vec::Vec; +use core::sync::atomic::{AtomicUsize, Ordering}; use super::coverage::Coverage; use super::lookup_list::Lookup; @@ -128,6 +131,13 @@ const MIN_COVERAGE_BUDGET: usize = 1 << 14; /// Coverage entries per subtable the budget grows by. const COVERAGE_BUDGET_PER_SUBTABLE: usize = 64; +/// Coverage entries all of one table's accelerators may read together: +/// a few times what fits in the table (an entry takes at least two +/// bytes), and at least 64 Ki. +fn table_coverage_budget(table_len: usize) -> usize { + table_len.saturating_mul(2).saturating_add(1 << 16) +} + /// One lookup's accelerator: the digest of every glyph a subtable of /// it can start at, and each subtable's own digest, by subtable index. #[derive(Debug)] @@ -137,8 +147,14 @@ pub(crate) struct LookupAccel { } impl LookupAccel { - /// Builds the accelerator of `lookup`, a lookup of `table`. - pub(crate) fn build(table: LayoutTable, lookup: &Lookup<'_>) -> Self { + /// Builds the accelerator of `lookup`, a lookup of `table`, reading + /// at most what the lookup's budget and `table_budget`, which the + /// table's other lookups share, leave. + pub(crate) fn build( + table: LayoutTable, + lookup: &Lookup<'_>, + table_budget: &AtomicUsize, + ) -> Self { let count = usize::from(lookup.subtable_count()); let mut budget = MIN_COVERAGE_BUDGET.max(count.saturating_mul(COVERAGE_BUDGET_PER_SUBTABLE)); @@ -148,7 +164,7 @@ impl LookupAccel { let d = lookup .subtable_bytes(i) .and_then(|bytes| primary_coverage(table, lookup.lookup_type(), bytes)) - .and_then(|cov| coverage_digest(&cov, &mut budget)) + .and_then(|cov| coverage_digest(&cov, &mut budget, table_budget)) .unwrap_or(Digest::FULL); digest.union(&d); d @@ -304,15 +320,18 @@ impl Accel<'_, '_> { pub(crate) struct LookupAccels { table: LayoutTable, lookups: Box<[OnceBox]>, + /// Coverage entries the accelerators may still read. + budget: AtomicUsize, } impl LookupAccels { - /// Room for the `lookup_count` lookups of a `table` table, none - /// built yet. - pub(crate) fn new(table: LayoutTable, lookup_count: u16) -> Self { + /// Room for the `lookup_count` lookups of a `table` table of + /// `table_len` bytes, none built yet. + pub(crate) fn new(table: LayoutTable, lookup_count: u16, table_len: usize) -> Self { Self { table, lookups: (0..lookup_count).map(|_| OnceBox::new()).collect(), + budget: AtomicUsize::new(table_coverage_budget(table_len)), } } @@ -321,7 +340,9 @@ impl LookupAccels { /// lookups, its coverages are read for this one use. pub(crate) fn get<'a>(&self, index: u16, lookup: &Lookup<'a>) -> Accel<'_, 'a> { match self.lookups.get(usize::from(index)) { - Some(slot) => Accel::Built(slot.get_or_init(|| LookupAccel::build(self.table, lookup))), + Some(slot) => Accel::Built( + slot.get_or_init(|| LookupAccel::build(self.table, lookup, &self.budget)), + ), None => Accel::Direct(DirectAccel::new(self.table, lookup)), } } @@ -368,10 +389,11 @@ pub(crate) struct LayoutCache { } impl LayoutCache { - /// An empty cache for a `table` table of `lookup_count` lookups. - pub(crate) fn new(table: LayoutTable, lookup_count: u16) -> Self { + /// An empty cache for a `table` table of `lookup_count` lookups and + /// `table_len` bytes. + pub(crate) fn new(table: LayoutTable, lookup_count: u16, table_len: usize) -> Self { Self { - accels: LookupAccels::new(table, lookup_count), + accels: LookupAccels::new(table, lookup_count, table_len), maps: FeatureMaps::new(), plans: StagePlans::new(), } @@ -398,11 +420,28 @@ pub(crate) fn accel_for<'c, 'a>( } /// The digest of `cov`'s glyphs, or `None` when reading them all would -/// overrun `budget` (the caller then admits every glyph). Spends the -/// entries read. -fn coverage_digest(cov: &Coverage<'_>, budget: &mut usize) -> Option { +/// overrun `budget` or `table_budget` (the caller then admits every +/// glyph). Spends the entries read from both. +fn coverage_digest( + cov: &Coverage<'_>, + budget: &mut usize, + table_budget: &AtomicUsize, +) -> Option { let entries = usize::from(cov.len()); - *budget = budget.checked_sub(entries)?; + let left = budget.checked_sub(entries)?; + // Spend from the shared budget with a compare-and-swap loop, so two + // threads building accelerators of one table cannot both take its + // last entries. + let mut shared = table_budget.load(Ordering::Relaxed); + loop { + let rest = shared.checked_sub(entries)?; + match table_budget.compare_exchange_weak(shared, rest, Ordering::Relaxed, Ordering::Relaxed) + { + Ok(_) => break, + Err(now) => shared = now, + } + } + *budget = left; let mut d = Digest::EMPTY; cov.for_each_range(|first, last| d.add_range(first, last)); Some(d) @@ -569,7 +608,7 @@ mod tests { let data = lookup_list(1, &[single(&[10, 20]), single(&[700]), vec![0, 9]]); let list = LookupList::parse(&data).unwrap(); let lookup = list.get(0).unwrap(); - let accel = LookupAccel::build(LayoutTable::Gsub, &lookup); + let accel = LookupAccel::build(LayoutTable::Gsub, &lookup, &AtomicUsize::new(usize::MAX)); assert!(accel.subtable_may_have(0, 10)); assert!(accel.subtable_may_have(0, 20)); assert!(!accel.subtable_may_have(0, 700)); @@ -582,7 +621,11 @@ mod tests { assert_eq!(*accel.digest(), Digest::FULL); let data = lookup_list(1, &[single(&[10, 20]), single(&[700])]); let list = LookupList::parse(&data).unwrap(); - let accel = LookupAccel::build(LayoutTable::Gsub, &list.get(0).unwrap()); + let accel = LookupAccel::build( + LayoutTable::Gsub, + &list.get(0).unwrap(), + &AtomicUsize::new(usize::MAX), + ); assert!(!accel.digest().may_have(11)); assert!(!accel.digest().may_have(5000)); } @@ -596,17 +639,40 @@ mod tests { let subs: Vec> = (0..10).map(|_| sub.clone()).collect(); let data = lookup_list(1, &subs); let list = LookupList::parse(&data).unwrap(); - let accel = LookupAccel::build(LayoutTable::Gsub, &list.get(0).unwrap()); + let accel = LookupAccel::build( + LayoutTable::Gsub, + &list.get(0).unwrap(), + &AtomicUsize::new(usize::MAX), + ); assert!(!accel.subtable_may_have(0, 65_001)); assert!(accel.subtable_may_have(9, 65_001)); } + #[test] + fn a_table_budget_bounds_reads_across_lookups() { + // Every lookup reads the same 4000-entry coverage; once the + // table's budget is spent, later lookups admit every glyph. + let glyphs: Vec = (0..4000).map(|g| g * 2).collect(); + let data = lookup_list(1, &[single(&glyphs)]); + let list = LookupList::parse(&data).unwrap(); + let lookup = list.get(0).unwrap(); + let budget = AtomicUsize::new(10_000); + let first = LookupAccel::build(LayoutTable::Gsub, &lookup, &budget); + let second = LookupAccel::build(LayoutTable::Gsub, &lookup, &budget); + let third = LookupAccel::build(LayoutTable::Gsub, &lookup, &budget); + assert!(!first.subtable_may_have(0, 65_001)); + assert!(!second.subtable_may_have(0, 65_001)); + assert!(third.subtable_may_have(0, 65_001)); + assert_eq!(budget.load(Ordering::Relaxed), 2000); + assert_eq!(table_coverage_budget(1000), 2000 + (1 << 16)); + } + #[test] fn accelerators_are_built_once_and_agree_with_the_coverages() { let data = lookup_list(1, &[single(&[10]), single(&[3000])]); let list = LookupList::parse(&data).unwrap(); let lookup = list.get(0).unwrap(); - let accels = LookupAccels::new(LayoutTable::Gsub, 3); + let accels = LookupAccels::new(LayoutTable::Gsub, 3, data.len()); assert_eq!(accels.built(), 0); let first = accel_for(None, LayoutTable::Gsub, 0, &lookup); assert!(matches!(first, Accel::Direct(_))); From a6d0d395557fb46f48b969991094e61ddbe945db Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:58:21 -0800 Subject: [PATCH 15/15] Fuzz Font::with_variations and Face::glyph_outlines The shape target now binds half its fonts from user-space axis values, and the face target checks that a GlyphOutlines run draws every glyph exactly as glyph_outline_at_coords does, errors included. --- fuzz/fuzz_targets/face.rs | 6 ++++++ fuzz/src/lib.rs | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/fuzz/fuzz_targets/face.rs b/fuzz/fuzz_targets/face.rs index f3ac5dd5..6226b491 100644 --- a/fuzz/fuzz_targets/face.rs +++ b/fuzz/fuzz_targets/face.rs @@ -94,4 +94,10 @@ fn exercise(face: &Face<'_>, knobs: &mut Knobs<'_>) { let _ = face.svg_document(gid); let _ = face.colr_paint(gid); } + // A run of outlines with the tables read once draws each glyph as + // the face draws it, errors included. + let outlines = face.glyph_outlines(&coords); + for gid in glyph_ids(face, knobs) { + assert_eq!(outlines.outline(gid), face.glyph_outline_at_coords(gid, &coords)); + } } diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index c6a7813e..856fd518 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -106,7 +106,20 @@ pub fn axis_coords(face: &Face<'_>, knobs: &mut Knobs<'_>) -> Vec { pub fn shape_samples(face: Face<'_>, knobs: &mut Knobs<'_>, extra: Option<&str>) { let coords = axis_coords(&face, knobs); let size = f32::from(knobs.byte()) + 1.0; - let font = Font::new(face, size).with_coords(&coords); + // Half the time the instance comes from user-space values. + let font = if knobs.byte() & 1 == 1 { + let variations: Vec<([u8; 4], f32)> = match face.fvar() { + Ok(Some(fvar)) => fvar + .axes() + .iter() + .map(|a| (a.tag, a.default_value + knobs.coord() * 500.0)) + .collect(), + _ => Vec::new(), + }; + Font::new(face, size).with_variations(&variations) + } else { + Font::new(face, size).with_coords(&coords) + }; let mut features = Vec::new(); for _ in 0..(knobs.byte() % 6) { let i = usize::from(knobs.byte()) % FEATURE_TAGS.len();