HarfBuzz Study: AAT layout caches
behdad
August 18, 2025
Introduction
This document explains how caches involved in AAT lookups (morx and kerx) work in HarfBuzz, with the aim of facilitating their port to HarfRust.
For introduction to caching in HarfBuzz and data-structures used, see this document. For more detailed exploration of caches used in OpenType Layout processing, see this one.
Criteria
Unlike OpenType Layout, fonts using AAT for layout are rare and limited to a few shipped by Apple on their platforms. And these fonts use a small number of subtables. For these reasons, we allow HarfBuzz to be slightly more memory-hungry than we allow for general OpenType fonts. That is, for example, we use a full integer set, hb_bit_set_t, instead of the more compact but less effective hb_digest_t.
The caches
Machine class cache
Each state machine, in a morx chain subtable or kerx subtable, gets a hb_cache_t for its glyph class value. This is the same cache configuration (hb_aat_class_cache_t) as an OpenType layout mapping cache (hb_ot_layout_mapping_cache_t in HarfBuzz, MappingCache in HarfRust), of 256 bytes per cache. In the code this is called machine_class_cache.
Kerning left/right glyph sets
For kerning, an hb_bit_set_t of all left-side glyphs and all right-side glyphs that participate in any kerning are kept, and 0 returned at lookup time if the pair does not pass the bit-set tests. In the code, this is called left_set / right_set.
Machine & buffer glyph sets
Similar to the OpenType caching scheme, we want to be able to completely skip applying a state-machine if we know it will have no effect on the buffer. Unlike OpenType, where we use an hb_set_digest_t for this purpose, in AAT, we use full-on hb_bit_set_t.
To achieve this, we keep an hb_bit_set_t of every glyph in the buffer. To avoid allocations for empty lines, we only do this if buffer length is at least 4. The relevant code is in setup_buffer_glyph_set and the variables are called buffer_glyph_set and using_buffer_glyph_set.
On the machine side of things, we keep an hb_bit_set_t per machine, of all the glyphs that can initiate a non-trivial movement from the start state. This cache is called machine_glyph_set and is constructed using collect_initial_glyphs logic.
Note: Apple morx table specification says:
If the 'morx' table version is 3 or greater, then the last subtable in the chain is followed by a subtableGlyphCoverageArray, as described below.
This field, if present, is a dense bitset of all glyphs participating in the state machine. It goes on to say:
In practice, for a given run of text, a coverage bitfield is generated for the glyphs in that run. That bitfield is then ANDed with the coverage bitfield for each subtable. If the result is zero, then none of the glyphs in the run is used by the subtable and the subtable may be skipped.
It is generally best that the coverage bitfield include as few glyphs as possible, so as to maximize the probability of CoreText's skipping the subtable. For example, in a ligature subtable which forms "ff", "fi", and "fl" ligatures, it would be sufficient for the subtable's coverage bitfield to include only the "f" glyph. This way, when laying out the text, "Indicate locale," CoreText would be able to skip the subtable, despite the presence of both "i" and "l".
That is, the font can provide what we calculate as machine_glyph_set. However, this also puts the onus on the font designer / compiler to calculate a minimal initial-glyphs set. We have not seen any fonts using this table, and as such, we currently don’t use it and always calculate our own machine_glyph_set.
Feature mapping cache
To handle arbitrary user feature settings with state-machines, we need to be able to compile the hb_aat_map_t at shaping time. However, to reduce memory allocations and increase speed, an hb_aat_map_t corresponding to “no user features” is cached in the shape plan. See this commit.
Scratch bit-set
In HarfBuzz we go out of our way to make most operations, including shaping, malloc-free for most cases. To this end, always allocating an hb_bit_set_t per shape call is problematic. To address that, we cache an hb_bit_set_t in the AAT table’s (morx / kerx) scratch area and borrow it (atomically) if available during the shape call.
If this turns out to be hard to implement in Rust, we can add a hb_bit_set_t to the GlyphBuffer structure itself. This is not my preferred way to do it, but totally feasible.