diff --git a/docs/STABILITY.md b/docs/STABILITY.md index b7989fc..349ef89 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` and the C API both default to HarfBuzz's `MonotoneGraphemes` (`MONOTONE_GRAPHEMES`); before 0.24.0 a Rust `Buffer` defaulted to `MonotoneCharacters`. 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/fuzz/fuzz_targets/face.rs b/fuzz/fuzz_targets/face.rs index f3ac5dd..6226b49 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 c6a7813..856fd51 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(); diff --git a/src/face.rs b/src/face.rs index c864abc..83218b2 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 0000000..b6d891a --- /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/face/outlines.rs b/src/face/outlines.rs index ea29892..9abf66a 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/src/font.rs b/src/font.rs index 7e4ffea..26a8c64 100644 --- a/src/font.rs +++ b/src/font.rs @@ -7,35 +7,34 @@ //! 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}; + use crate::error::Result; use crate::face::Face; use crate::tables::parse::hb_round_to; @@ -67,11 +66,36 @@ 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> { @@ -87,7 +111,8 @@ impl<'a> Font<'a> { Self { face, size, - coords: &[], + coords: Coords::Borrowed(&[]), + caches: FontCaches::new(), } } @@ -106,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 @@ -118,7 +147,8 @@ impl<'a> Font<'a> { Self { face: self.face.clone(), size, - coords: self.coords, + coords: self.coords.clone(), + caches: self.caches.clone(), } } @@ -128,19 +158,83 @@ 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] pub fn with_coords(self, coords: &'a [f32]) -> Self { Self { + caches: self.caches.for_other_coords(), + face: self.face, + size: self.size, + 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: Coords::Owned(coords.into()), } } @@ -150,6 +244,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)] @@ -224,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()); + } } diff --git a/src/font/cache.rs b/src/font/cache.rs new file mode 100644 index 0000000..fa4a2b6 --- /dev/null +++ b/src/font/cache.rs @@ -0,0 +1,364 @@ +//! 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`]), +//! 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: +//! 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. +//! +//! 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. +//! +//! 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, 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; +use core::fmt; +use core::sync::atomic::{AtomicBool, AtomicU32, 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>, + instance: 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(), + instance: 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())) + } + + /// 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, no instance cache. + pub(crate) fn for_other_coords(&self) -> Self { + 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()) + } +} + +/// 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), + instance: share(&self.instance), + 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 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, table_len)) + } + + /// What the font keeps for its GPOS, which has `lookup_count` + /// 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, table_len)) + } + + 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::() + } +} + +/// 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); + } + + #[test] + fn caches_are_send_and_sync() { + fn check() {} + check::(); + } +} diff --git a/src/lib.rs b/src/lib.rs index ebe96b5..7d344b9 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 @@ -95,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/src/ot/indic/machine.rs b/src/ot/indic/machine.rs index 26d5aff..c81c751 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 7003f77..1e6ed7d 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/layout_select.rs b/src/ot/layout_select.rs index 7a28bac..aa6f5f0 100644 --- a/src/ot/layout_select.rs +++ b/src/ot/layout_select.rs @@ -34,9 +34,13 @@ //! //! [`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::tables::layout::{ActiveFeatures, LangSys, ScriptList}; +use crate::shape::Feature; +use crate::sync::OnceBox; +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. @@ -213,6 +217,586 @@ 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>, + pub(crate) plans: Option<&'a StagePlans>, +} + +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, + 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() + } +} + +/// 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], +} + +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]>, + 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; + // 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); + } + 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(); + f.debug_struct("FeatureMaps").field("kept", &kept).finish() + } +} + #[cfg(test)] mod tests { use super::*; @@ -584,4 +1168,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, 1 << 20); + 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/myanmar/machine.rs b/src/ot/myanmar/machine.rs index f32fa03..159deb8 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/syllabic/stage.rs b/src/ot/syllabic/stage.rs index 21b36cb..6278300 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; @@ -117,9 +118,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 @@ -129,15 +131,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 @@ -154,15 +152,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 @@ -241,18 +231,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() @@ -273,13 +258,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); @@ -337,10 +316,46 @@ 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; + } + // 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, + joiners: p.joiners, + per_syllable: p.per_syllable, + }); let mut slots: Vec = glyphs .iter_mut() @@ -361,6 +376,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/ot/use_shaper/machine.rs b/src/ot/use_shaper/machine.rs index 707ad8f..bd6ea6a 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, diff --git a/src/shape.rs b/src/shape.rs index 544105e..79d31a4 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 //! @@ -130,6 +131,7 @@ mod hangul; mod ignorables; mod joiners; mod kern; +mod lazy; mod lig; mod native_direction; mod normalize; @@ -334,16 +336,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/features.rs b/src/shape/features.rs index 221f666..576a85e 100644 --- a/src/shape/features.rs +++ b/src/shape/features.rs @@ -6,10 +6,14 @@ 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, + gsub_lookup_may_apply_to_ids, StageLookup, +}; 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; @@ -167,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); } @@ -431,7 +448,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 +473,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 @@ -487,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/gpos.rs b/src/shape/gpos.rs index c413657..0330870 100644 --- a/src/shape/gpos.rs +++ b/src/shape/gpos.rs @@ -32,16 +32,19 @@ 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, }; 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, }; -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, @@ -112,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>, @@ -182,37 +203,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 +267,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 +333,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 +374,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 ac60a2d..19d2f77 100644 --- a/src/shape/gsub.rs +++ b/src/shape/gsub.rs @@ -21,16 +21,15 @@ 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::ot::layout_select::PlannedLookup; 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 +96,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 +113,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(); @@ -126,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 @@ -152,7 +185,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 +220,9 @@ pub(super) fn apply_gsub_lookup( let Some(lookup) = gsub.lookup_list().get(lookup_idx) else { return; }; + if gsub.has_cache() && !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 +230,93 @@ 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. +/// +/// 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| { + !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.has_cache() + || 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 +/// 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 +350,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 +489,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 66eeedb..fa1b775 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 0000000..2b261fd --- /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 ae65723..748b4c2 100644 --- a/src/shape/pipeline.rs +++ b/src/shape/pipeline.rs @@ -41,7 +41,9 @@ fn starts_mongolian(first: char) -> bool { /// 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 /// @@ -59,6 +61,10 @@ fn starts_mongolian(first: char) -> bool { // 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* for Mongolian text when the caller never chose a // direction (see `starts_mongolian`). Mongolian's traditional @@ -246,20 +252,32 @@ pub fn shape(font: &Font<'_>, buffer: &Buffer, features: &[Feature]) -> Result, buffer: &Buffer, features: &[Feature]) -> Result, buffer: &Buffer, features: &[Feature]) -> Result = 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()), @@ -280,39 +297,24 @@ pub(super) fn subtract_vertical_origins(advances: &FontAdvances<'_, '_>, 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 @@ -371,7 +373,10 @@ fn zero_mark_widths(glyphs: &mut [Glyph], gdef: Option<&Gdef<'_>>, 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], @@ -406,6 +411,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 @@ -425,7 +437,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()?) @@ -465,9 +485,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 @@ -513,10 +561,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 @@ -556,7 +605,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); } @@ -622,17 +673,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); diff --git a/src/shape/required.rs b/src/shape/required.rs index 7c2f570..cd5ba7e 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 f506803..4c5c1c3 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/shape/tests.rs b/src/shape/tests.rs index 80e6a68..f4bca47 100644 --- a/src/shape/tests.rs +++ b/src/shape/tests.rs @@ -270,3 +270,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/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..a5f2fc3 --- /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 f24838a..ef3eb3b 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,39 @@ 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 + } + + /// 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> { + LayoutView { + script_list: self.script_list, + features: self.features(), + language_tags: self.language_tags, + maps: self.cache.map(|c| &c.maps), + plans: self.cache.map(|c| &c.plans), + } + } + + /// 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 fe13de8..c7d274b 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,45 @@ 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 + } + + /// 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() + } + + /// 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> { + LayoutView { + script_list: self.script_list, + features: self.features(), + language_tags: self.language_tags, + maps: self.cache.map(|c| &c.maps), + plans: self.cache.map(|c| &c.plans), + } + } + + /// 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 0000000..a8938ae --- /dev/null +++ b/src/tables/layout/accel.rs @@ -0,0 +1,699 @@ +//! 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, +//! 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; +use super::skip_iter::LayoutTable; +use crate::ot::layout_select::{FeatureMaps, StagePlans}; +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; + +/// 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)] +pub(crate) struct LookupAccel { + digest: Digest, + subtables: Box<[Digest]>, +} + +impl LookupAccel { + /// 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)); + 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, table_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]>, + /// Coverage entries the accelerators may still read. + budget: AtomicUsize, +} + +impl LookupAccels { + /// 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)), + } + } + + /// 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, &self.budget)), + ), + 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, 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 { + /// 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, table_len), + 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.plans.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` 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()); + 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) +} + +/// 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, &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)); + 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(), + &AtomicUsize::new(usize::MAX), + ); + 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(), + &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, data.len()); + 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 e673421..13e0b88 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,15 +130,36 @@ 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() } + /// 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); + } + } + } + } + + #[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]]); @@ -140,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; @@ -158,6 +187,7 @@ impl<'a> Coverage<'a> { None } + #[inline(always)] fn index_format2(&self, glyph_id: u16) -> Option { if self.count == 0 { return None; diff --git a/src/tables/layout/feature_variations.rs b/src/tables/layout/feature_variations.rs index c25588c..72b3263 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 03f707a..459deb6 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; diff --git a/tests/font_caches.rs b/tests/font_caches.rs new file mode 100644 index 0000000..be75404 --- /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::>(); +} diff --git a/tests/glyph_outlines.rs b/tests/glyph_outlines.rs new file mode 100644 index 0000000..ace731e --- /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); + } +} diff --git a/tests/glyph_points.rs b/tests/glyph_points.rs index 21fb11f..0afa9cd 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". diff --git a/tests/repeated_features.rs b/tests/repeated_features.rs new file mode 100644 index 0000000..82f8e64 --- /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 + ); +}