Repository: googlefonts/fontations
Pull request #913 (2024-05-24 23:29:19)

[IntSet] Implement the set processing framework.
This processing framework allows applying arbitrary bitwise operations between two bit sets. Ported fairly directly from the harfbuzz implementation in https://github.com/harfbuzz/harfbuzz/blob/main/src/hb-bit-set.hh#L483. In the public api of IntSet I've added support for union() and intersect().


Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 6519b839..d9084713 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,6 +12,7 @@ members = [
     "fauntlet",
     "klippa",
     "int-set",
+    "fuzz",
 ]
 
 [workspace.dependencies]
diff --git a/fauntlet/Cargo.toml b/fauntlet/Cargo.toml
index 3f3748b6..15e21965 100644
--- a/fauntlet/Cargo.toml
+++ b/fauntlet/Cargo.toml
@@ -9,7 +9,7 @@ readme = "README.md"
 publish = false
 
 [dependencies]
-skrifa = { version = "0.19.1", path = "../skrifa" }
+skrifa = { version = "0.19.2", path = "../skrifa" }
 freetype-rs = "0.32.0"
 memmap2 = "0.5.10"
 rayon = "1.8.0"
diff --git a/font-codegen/Cargo.toml b/font-codegen/Cargo.toml
index 39763a18..f57bb18b 100644
--- a/font-codegen/Cargo.toml
+++ b/font-codegen/Cargo.toml
@@ -12,7 +12,7 @@ name = "codegen"
 path = "src/main.rs"
 
 [dependencies]
-font-types = { version = "0.5.3", path = "../font-types" }
+font-types = { version = "0.5.4", path = "../font-types" }
 rustfmt-wrapper = "0.2"
 regex = "1.5"
 miette = { version =  "5.0", features = ["fancy"] }
diff --git a/font-types/Cargo.toml b/font-types/Cargo.toml
index fd85b37f..3fccfd58 100644
--- a/font-types/Cargo.toml
+++ b/font-types/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "font-types"
-version = "0.5.3"
+version = "0.5.4"
 edition = "2021"
 license = "MIT/Apache-2.0"
 description = "Scalar types used in fonts."
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
new file mode 100644
index 00000000..da5f1d4e
--- /dev/null
+++ b/fuzz/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "fuzz"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[package.metadata]
+cargo-fuzz = true
+
+# cargo-release settings
+[package.metadata.release]
+release = false
+
+[dependencies]
+libfuzzer-sys = "0.4"
+skrifa = { path="../skrifa" }
+
+[[bin]]
+name = "fuzz_skrifa"
+path = "fuzz_targets/fuzz_skrifa.rs"
+test = false
+doc = false
\ No newline at end of file
diff --git a/fuzz/README.md b/fuzz/README.md
new file mode 100644
index 00000000..d6812f61
--- /dev/null
+++ b/fuzz/README.md
@@ -0,0 +1,15 @@
+# Fuzz
+
+Fuzzing is generally run by https://github.com/google/oss-fuzz.
+
+To run fuzzing locally:
+
+```shell
+# Make sure you have cargo-fuzz and the nightly toolchain
+$ cargo install cargo-fuzz
+$ rustup install nightly
+
+# Build the fuzzer
+$ cargo +nightly  fuzz build -O --debug-assertions
+$ target/x86_64-unknown-linux-gnu/release/fuzz_skrifa
+```
diff --git a/fuzz/fuzz_targets/fuzz_skrifa.rs b/fuzz/fuzz_targets/fuzz_skrifa.rs
new file mode 100644
index 00000000..e151bcc9
--- /dev/null
+++ b/fuzz/fuzz_targets/fuzz_skrifa.rs
@@ -0,0 +1,15 @@
+#![no_main]
+use libfuzzer_sys::fuzz_target;
+use skrifa::FontRef;
+
+fn do_skrifa_things(data: &[u8]) -> Result<(), String> {
+    let _font = FontRef::new(data).map_err(|e| format!("{e}"))?;
+
+    // TODO: put skrifa through it's paces
+
+    Ok(())
+}
+
+fuzz_target!(|data: &[u8]| {
+    let _ = do_skrifa_things(data);
+});
diff --git a/int-set/Cargo.toml b/int-set/Cargo.toml
index 26382ddd..242d2564 100644
--- a/int-set/Cargo.toml
+++ b/int-set/Cargo.toml
@@ -6,4 +6,12 @@ license = "MIT/Apache-2.0"
 description = "A fast sparse and invertible bit set for u32's. Port of harfbuzz's hb_set_t."
 
 [dependencies]
-font-types = { version = "0.5.3", path = "../font-types"}
+font-types = { version = "0.5.4", path = "../font-types"}
+
+[dev-dependencies]
+criterion = "0.5.1"
+rand = "0.8.5"
+
+[[bench]]
+name = "int_set_benchmark"
+harness = false
diff --git a/int-set/benches/int_set_benchmark.rs b/int-set/benches/int_set_benchmark.rs
new file mode 100644
index 00000000..44d336d8
--- /dev/null
+++ b/int-set/benches/int_set_benchmark.rs
@@ -0,0 +1,158 @@
+use criterion::{black_box, criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion};
+use int_set::IntSet;
+use rand::Rng;
+
+fn random_set(size: u32, max_value: u32) -> IntSet<u32> {
+    let mut rng = rand::thread_rng();
+    let mut set = IntSet::<u32>::empty();
+    for _ in 0..size {
+        loop {
+            let candidate: u32 = rng.gen::<u32>() % max_value;
+            if set.insert(candidate) {
+                break;
+            }
+        }
+    }
+    set
+}
+
+struct SetTest {
+    set_size: u32,
+    density: u32,
+}
+
+impl SetTest {
+    fn max_value(&self) -> u32 {
+        self.density * self.set_size
+    }
+}
+
+impl std::fmt::Display for SetTest {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}/{}", self.set_size, self.density)
+    }
+}
+
+fn set_parameters() -> Vec<SetTest> {
+    let mut inputs: Vec<SetTest> = Vec::<SetTest>::new();
+    for density in [2, 8, 64, 512] {
+        for set_size in [1024, 4096, 32_768, 65_536] {
+            inputs.push(SetTest { set_size, density })
+        }
+    }
+    inputs
+}
+
+pub fn insert_benchmark(c: &mut Criterion) {
+    const NUM_INSERTS: u32 = 1000;
+    let inputs = set_parameters();
+
+    for input in inputs {
+        c.bench_with_input(
+            BenchmarkId::new("BM_SetInsert_1000", &input),
+            &input,
+            |b, p: &SetTest| {
+                let set = random_set(p.set_size, p.max_value());
+                b.iter_batched(
+                    || set.clone(),
+                    |mut s| {
+                        for i in 0..NUM_INSERTS {
+                            let v: u32 = i.wrapping_mul(2_654_435_761) % p.max_value();
+                            s.insert(v);
+                        }
+                    },
+                    BatchSize::SmallInput,
+                )
+            },
+        );
+    }
+}
+
+pub fn ordered_extend_benchmark(c: &mut Criterion) {
+    let num_inserts = 1000u32;
+    let inputs = set_parameters();
+
+    for input in inputs {
+        c.bench_with_input(
+            BenchmarkId::new("BM_SetOrderedInsert_1000", &input),
+            &input,
+            |b, p: &SetTest| {
+                let set = random_set(p.set_size, p.max_value());
+                b.iter_batched(
+                    || set.clone(),
+                    |mut s| {
+                        s.extend(0..num_inserts);
+                    },
+                    BatchSize::SmallInput,
+                )
+            },
+        );
+    }
+}
+
+pub fn lookup_random_benchmark(c: &mut Criterion) {
+    let inputs = set_parameters();
+
+    for input in inputs {
+        let set = random_set(input.set_size, input.max_value());
+        let mut needle = input.max_value() / 2;
+        c.bench_with_input(
+            BenchmarkId::new("BM_SetLookup/random", &input),
+            &set,
+            |b, s: &IntSet<u32>| {
+                b.iter(|| {
+                    needle += 12345;
+                    s.contains(needle % input.max_value())
+                })
+            },
+        );
+    }
+}
+
+pub fn lookup_ordered_benchmark(c: &mut Criterion) {
+    let inputs = set_parameters();
+
+    for input in inputs {
+        let set = random_set(input.set_size, input.max_value());
+        let mut needle = input.max_value() / 2;
+        c.bench_with_input(
+            BenchmarkId::new("BM_SetLookup/ordered", &input),
+            &set,
+            |b, s: &IntSet<u32>| {
+                b.iter(|| {
+                    needle += 3;
+                    s.contains(needle % input.max_value())
+                })
+            },
+        );
+    }
+}
+
+pub fn iteration_benchmark(c: &mut Criterion) {
+    let inputs = set_parameters();
+
+    for input in inputs {
+        let set = random_set(input.set_size, input.max_value());
+        c.bench_with_input(
+            BenchmarkId::new("BM_SetIteration", &input),
+            &set,
+            |b, s: &IntSet<u32>| {
+                b.iter(|| {
+                    for v in s.iter() {
+                        black_box(v);
+                    }
+                })
+            },
+        );
+    }
+}
+
+criterion_group!(
+    benches,
+    insert_benchmark,
+    ordered_extend_benchmark,
+    lookup_random_benchmark,
+    lookup_ordered_benchmark,
+    iteration_benchmark,
+);
+criterion_main!(benches);
diff --git a/int-set/src/bitpage.rs b/int-set/src/bitpage.rs
index aea3faa4..2fd67d0f 100644
--- a/int-set/src/bitpage.rs
+++ b/int-set/src/bitpage.rs
@@ -135,6 +135,30 @@ impl BitPage {
         (*self.element(val) & elem_index_bit_mask(val)) != 0
     }
 
+    pub(crate) fn union(a: &BitPage, b: &BitPage) -> BitPage {
+        a.process(b, |a, b| a | b)
+    }
+
+    pub(crate) fn intersect(a: &BitPage, b: &BitPage) -> BitPage {
+        a.process(b, |a, b| a & b)
+    }
+
+    pub(crate) fn subtract(a: &BitPage, b: &BitPage) -> BitPage {
+        a.process(b, |a, b| a & !b)
+    }
+
+    fn process<Op>(&self, other: &BitPage, op: Op) -> BitPage
+    where
+        Op: Fn(Element, Element) -> Element,
+    {
+        let mut out = BitPage::new_zeroes();
+        out.mark_dirty();
+        for i in 0usize..(PAGE_SIZE as usize) {
+            out.storage[i] = op(self.storage[i], other.storage[i]);
+        }
+        out
+    }
+
     fn mark_dirty(&mut self) {
         self.len.set(u32::MAX);
     }
@@ -248,7 +272,7 @@ mod test {
 
     impl BitPage {
         /// Create a new page with all bits set.
-        pub(crate) fn new_ones() -> Self {
+        fn new_ones() -> Self {
             Self {
                 storage: [Element::MAX; PAGE_SIZE as usize],
                 len: Cell::new(PAGE_SIZE * ELEM_BITS),
@@ -256,6 +280,16 @@ mod test {
         }
     }
 
+    impl FromIterator<u32> for BitPage {
+        fn from_iter<I: IntoIterator<Item = u32>>(iter: I) -> Self {
+            let mut out = BitPage::new_zeroes();
+            for v in iter {
+                out.insert(v);
+            }
+            out
+        }
+    }
+
     #[test]
     fn test_iter_bit_indices() {
         let items: Vec<_> = Iter::new(0).collect();
@@ -461,6 +495,46 @@ mod test {
         assert_eq!(items, vec![0, 12, 13, 23, 63, 64, 78, 400, 511,])
     }
 
+    #[test]
+    fn union() {
+        let a = BitPage::new_zeroes();
+        let b = BitPage::from_iter([32, 400]);
+        let c = BitPage::from_iter([32, 200]);
+        let d = BitPage::from_iter([32, 200, 400]);
+
+        assert_eq!(BitPage::union(&a, &b), b);
+        assert_eq!(BitPage::union(&b, &a), b);
+        assert_eq!(BitPage::union(&b, &c), d);
+        assert_eq!(BitPage::union(&c, &b), d);
+    }
+
+    #[test]
+    fn intersect() {
+        let a = BitPage::new_zeroes();
+        let b = BitPage::from_iter([32, 400]);
+        let c = BitPage::from_iter([32, 200]);
+        let d = BitPage::from_iter([32]);
+
+        assert_eq!(BitPage::intersect(&a, &b), a);
+        assert_eq!(BitPage::intersect(&b, &a), a);
+        assert_eq!(BitPage::intersect(&b, &c), d);
+        assert_eq!(BitPage::intersect(&c, &b), d);
+    }
+
+    #[test]
+    fn subtract() {
+        let a = BitPage::new_zeroes();
+        let b = BitPage::from_iter([32, 400]);
+        let c = BitPage::from_iter([32, 200]);
+        let d = BitPage::from_iter([400]);
+        let e = BitPage::from_iter([200]);
+
+        assert_eq!(BitPage::subtract(&a, &b), a);
+        assert_eq!(BitPage::subtract(&b, &a), b);
+        assert_eq!(BitPage::subtract(&b, &c), d);
+        assert_eq!(BitPage::subtract(&c, &b), e);
+    }
+
     #[test]
     #[allow(clippy::mutable_key_type)]
     fn hash_and_eq() {
diff --git a/int-set/src/bitset.rs b/int-set/src/bitset.rs
index ed587325..317dbbb3 100644
--- a/int-set/src/bitset.rs
+++ b/int-set/src/bitset.rs
@@ -3,6 +3,7 @@
 use super::bitpage::BitPage;
 use super::bitpage::PAGE_BITS;
 use std::cell::Cell;
+use std::cmp::Ordering;
 use std::hash::Hash;
 use std::ops::RangeInclusive;
 
@@ -153,6 +154,26 @@ impl BitSet {
         self.len.get()
     }
 
+    /// Sets the members of this set to the union of self and other.
+    pub(crate) fn union(&mut self, other: &BitSet) {
+        self.process(BitPage::union, other);
+    }
+
+    /// Sets the members of this set to the intersection of self and other.
+    pub(crate) fn intersect(&mut self, other: &BitSet) {
+        self.process(BitPage::intersect, other);
+    }
+
+    /// Sets the members of this set to self - other.
+    pub(crate) fn subtract(&mut self, other: &BitSet) {
+        self.process(BitPage::subtract, other);
+    }
+
+    /// Sets the members of this set to other - self.
+    pub(crate) fn reversed_subtract(&mut self, other: &BitSet) {
+        self.process(|a, b| BitPage::subtract(b, a), other);
+    }
+
     pub(crate) fn iter(&self) -> impl DoubleEndedIterator<Item = u32> + '_ {
         self.iter_non_empty_pages().flat_map(|(major, page)| {
             let base = self.major_start(major);
@@ -172,6 +193,205 @@ impl BitSet {
         self.iter_pages().filter(|(_, page)| !page.is_empty())
     }
 
+    /// Determine the passthrough behaviour of the operator.
+    ///
+    /// The passthrough behaviour is what happens to a page on one side of the operation if the other side is 0.
+    /// For example union passes through both left and right sides since it preserves the left or right side when
+    /// the other side is 0. Knowing this lets us optimize some cases when only one page is present on one side.
+    fn passthrough_behavior<Op>(op: &Op) -> (bool, bool)
+    where
+        Op: Fn(&BitPage, &BitPage) -> BitPage,
+    {
+        let mut one: BitPage = BitPage::new_zeroes();
+        one.insert(0);
+        let zero: BitPage = BitPage::new_zeroes();
+
+        let passthrough_left: bool = op(&one, &zero).contains(0);
+        let passthrough_right: bool = op(&zero, &one).contains(0);
+
+        (passthrough_left, passthrough_right)
+    }
+
+    fn process<Op>(&mut self, op: Op, other: &BitSet)
+    where
+        Op: Fn(&BitPage, &BitPage) -> BitPage,
+    {
+        let (passthrough_left, passthrough_right) = BitSet::passthrough_behavior(&op);
+
+        self.mark_dirty();
+
+        let mut len_a = self.pages.len();
+        let len_b = other.pages.len();
+        let mut idx_a = 0;
+        let mut idx_b = 0;
+        let mut count = 0;
+        let mut write_idx = 0;
+
+        // Step 1: Estimate the new size of this set (in number of pages) after processing, and remove left side
+        //         pages that won't be needed.
+        while idx_a < len_a && idx_b < len_b {
+            let a_major = self.page_map[idx_a].major_value;
+            let b_major = other.page_map[idx_b].major_value;
+
+            match a_major.cmp(&b_major) {
+                Ordering::Equal => {
+                    if !passthrough_left {
+                        // If we don't passthrough the left side, then the only case where we
+                        // keep a page from the left is when there is also a page at the same major
+                        // on the right side. In this case move page_map entries that we're keeping
+                        // on the left side set to the front of the page_map vector. Otherwise if
+                        // we do passthrough left, then we we keep all left hand side pages and this
+                        // isn't necessary.
+                        if write_idx < idx_a {
+                            self.page_map[write_idx] = self.page_map[idx_a];
+                        }
+                        write_idx += 1;
+                    }
+
+                    count += 1;
+                    idx_a += 1;
+                    idx_b += 1;
+                }
+                Ordering::Less => {
+                    if passthrough_left {
+                        count += 1;
+                    }
+                    idx_a += 1;
+                }
+                Ordering::Greater => {
+                    if passthrough_right {
+                        count += 1;
+                    }
+                    idx_b += 1;
+                }
+            }
+        }
+
+        if passthrough_left {
+            count += len_a - idx_a;
+        }
+
+        if passthrough_right {
+            count += len_b - idx_b;
+        }
+
+        // Step 2: compact and resize for the new estimated left side size.
+        let mut next_page = len_a;
+        if !passthrough_left {
+            len_a = write_idx;
+            next_page = write_idx;
+            self.compact(write_idx);
+        }
+
+        self.resize(count);
+        let new_count = count;
+
+        // Step 3: process and apply op in-place from the last to first page.
+        idx_a = len_a;
+        idx_b = len_b;
+        while idx_a > 0 && idx_b > 0 {
+            match self.page_map[idx_a - 1]
+                .major_value
+                .cmp(&other.page_map[idx_b - 1].major_value)
+            {
+                Ordering::Equal => {
+                    idx_a -= 1;
+                    idx_b -= 1;
+                    count -= 1;
+                    self.page_map[count] = self.page_map[idx_a];
+                    *self.page_for_index_mut(count).unwrap() = op(
+                        self.page_for_index(idx_a).unwrap(),
+                        other.page_for_index(idx_b).unwrap(),
+                    );
+                }
+                Ordering::Greater => {
+                    idx_a -= 1;
+                    if passthrough_left {
+                        count -= 1;
+                        self.page_map[count] = self.page_map[idx_a];
+                    }
+                }
+                Ordering::Less => {
+                    idx_b -= 1;
+                    if passthrough_right {
+                        count -= 1;
+                        self.page_map[count].major_value = other.page_map[idx_b].major_value;
+                        self.page_map[count].index = next_page as u32;
+                        next_page += 1;
+                        *self.page_for_index_mut(count).unwrap() =
+                            other.page_for_index(idx_b).unwrap().clone();
+                    }
+                }
+            }
+        }
+
+        // Step 4: there are only pages left on one side now, finish processing them if the appropriate passthrough is
+        //         enabled.
+        if passthrough_left {
+            while idx_a > 0 {
+                idx_a -= 1;
+                count -= 1;
+                self.page_map[count] = self.page_map[idx_a];
+            }
+        }
+
+        if passthrough_right {
+            while idx_b > 0 {
+                idx_b -= 1;
+                count -= 1;
+                self.page_map[count].major_value = other.page_map[idx_b].major_value;
+                self.page_map[count].index = next_page as u32;
+                next_page += 1;
+                *self.page_for_index_mut(count).unwrap() =
+                    other.page_for_index(idx_b).unwrap().clone();
+            }
+        }
+
+        self.resize(new_count);
+    }
+
+    fn compact(&mut self, new_len: usize) {
+        let mut old_index_to_page_map_index = Vec::<usize>::with_capacity(self.pages.len());
+        old_index_to_page_map_index.resize(self.pages.len(), usize::MAX);
+
+        for i in 0usize..new_len {
+            old_index_to_page_map_index[self.page_map[i].index as usize] = i;
+        }
+
+        self.compact_pages(old_index_to_page_map_index);
+    }
+
+    fn compact_pages(&mut self, old_index_to_page_map_index: Vec<usize>) {
+        let mut write_index = 0;
+        for (i, page_map_index) in old_index_to_page_map_index
+            .iter()
+            .enumerate()
+            .take(self.pages.len())
+        {
+            if *page_map_index == usize::MAX {
+                continue;
+            }
+
+            if write_index < i {
+                self.pages[write_index] = self.pages[i].clone();
+            }
+
+            self.page_map[*page_map_index].index = write_index as u32;
+            write_index += 1;
+        }
+    }
+
+    fn resize(&mut self, new_len: usize) {
+        self.page_map.resize(
+            new_len,
+            PageInfo {
+                major_value: 0,
+                index: 0,
+            },
+        );
+        self.pages.resize(new_len, BitPage::new_zeroes());
+    }
+
     fn mark_dirty(&mut self) {
         self.len.set(usize::MAX);
     }
@@ -252,6 +472,19 @@ impl BitSet {
         let page_index = self.ensure_page_index_for_major(major_value);
         self.pages.get_mut(page_index).unwrap()
     }
+
+    // Return the mutable page at a given index
+    fn page_for_index_mut(&mut self, index: usize) -> Option<&mut BitPage> {
+        self.page_map
+            .get(index)
+            .and_then(|info| self.pages.get_mut(info.index as usize))
+    }
+
+    fn page_for_index(&self, index: usize) -> Option<&BitPage> {
+        self.page_map
+            .get(index)
+            .and_then(|info| self.pages.get(info.index as usize))
+    }
 }
 
 impl Extend<u32> for BitSet {
@@ -325,6 +558,14 @@ mod test {
     use super::*;
     use std::collections::HashSet;
 
+    impl FromIterator<u32> for BitSet {
+        fn from_iter<I: IntoIterator<Item = u32>>(iter: I) -> Self {
+            let mut out = BitSet::empty();
+            out.extend(iter);
+            out
+        }
+    }
+
     #[test]
     fn len() {
         let bitset = BitSet::empty();
@@ -503,6 +744,58 @@ mod test {
         assert_eq!(bitset.len(), 1);
     }
 
+    fn check_process<A, B, C, Op>(a: A, b: B, expected: C, op: Op)
+    where
+        A: IntoIterator<Item = u32>,
+        B: IntoIterator<Item = u32>,
+        C: IntoIterator<Item = u32>,
+        Op: Fn(&mut BitSet, &BitSet),
+    {
+        let mut result = BitSet::from_iter(a);
+        let b_set = BitSet::from_iter(b);
+        let expected_set = BitSet::from_iter(expected);
+        result.len();
+
+        op(&mut result, &b_set);
+        assert_eq!(result, expected_set);
+        assert_eq!(result.len(), expected_set.len());
+    }
+
+    #[test]
+    fn union() {
+        check_process([], [5], [5], |a, b| a.union(b));
+        check_process([128], [5], [128, 5], |a, b| a.union(b));
+        check_process([128], [], [128], |a, b| a.union(b));
+        check_process([1280], [5], [5, 1280], |a, b| a.union(b));
+        check_process([5], [1280], [5, 1280], |a, b| a.union(b));
+    }
+
+    #[test]
+    fn intersect() {
+        check_process([], [5], [], |a, b| a.intersect(b));
+        check_process([5], [], [], |a, b| a.intersect(b));
+        check_process([1, 5, 9], [5, 7], [5], |a, b| a.intersect(b));
+        check_process([1, 1000, 2000], [1000], [1000], |a, b| a.intersect(b));
+        check_process([1000], [1, 1000, 2000], [1000], |a, b| a.intersect(b));
+        check_process([1, 1000, 2000], [1000, 5000], [1000], |a, b| a.intersect(b));
+    }
+
+    #[test]
+    fn subtract() {
+        check_process([], [5], [], |a, b| a.subtract(b));
+        check_process([5], [], [5], |a, b| a.subtract(b));
+        check_process([5, 1000], [1000], [5], |a, b| a.subtract(b));
+        check_process([5, 1000], [5], [1000], |a, b| a.subtract(b));
+    }
+
+    #[test]
+    fn reversed_subtract() {
+        check_process([], [5], [5], |a, b| a.reversed_subtract(b));
+        check_process([5], [], [], |a, b| a.reversed_subtract(b));
+        check_process([1000], [5, 1000], [5], |a, b| a.reversed_subtract(b));
+        check_process([5], [5, 1000], [1000], |a, b| a.reversed_subtract(b));
+    }
+
     fn set_for_range(first: u32, last: u32) -> BitSet {
         let mut set = BitSet::empty();
         for i in first..=last {
diff --git a/int-set/src/lib.rs b/int-set/src/lib.rs
index 32ce9c00..4a2ed7ad 100644
--- a/int-set/src/lib.rs
+++ b/int-set/src/lib.rs
@@ -190,6 +190,32 @@ impl<T: Domain<T>> IntSet<T> {
         }
     }
 
+    /// Sets the members of this set to the union of self and other.
+    pub fn union(&mut self, other: &IntSet<T>) {
+        match (&mut self.0, &other.0) {
+            (Membership::Inclusive(a), Membership::Inclusive(b)) => a.union(b),
+            (Membership::Inclusive(a), Membership::Exclusive(b)) => {
+                a.reversed_subtract(b);
+                self.invert();
+            }
+            (Membership::Exclusive(a), Membership::Inclusive(b)) => a.subtract(b),
+            (Membership::Exclusive(a), Membership::Exclusive(b)) => a.intersect(b),
+        }
+    }
+
+    /// Sets the members of this set to the intersection of self and other.
+    pub fn intersect(&mut self, other: &IntSet<T>) {
+        match (&mut self.0, &other.0) {
+            (Membership::Inclusive(a), Membership::Inclusive(b)) => a.intersect(b),
+            (Membership::Inclusive(a), Membership::Exclusive(b)) => a.subtract(b),
+            (Membership::Exclusive(a), Membership::Inclusive(b)) => {
+                a.reversed_subtract(b);
+                self.invert();
+            }
+            (Membership::Exclusive(a), Membership::Exclusive(b)) => a.union(b),
+        }
+    }
+
     /// Returns first element in the set, if any. This element is always the minimum of all elements in the set.
     pub fn first(&self) -> Option<T> {
         return self.iter().next();
@@ -937,6 +963,122 @@ mod test {
         assert!(all.contains(4));
     }
 
+    struct SetOpInput {
+        has_x: bool,
+        inverted: bool,
+        has_page: bool,
+    }
+
+    impl SetOpInput {
+        fn get_all_inputs() -> Vec<SetOpInput> {
+            let mut result: Vec<SetOpInput> = vec![];
+            for has_x in [true, false] {
+                for inverted in [true, false] {
+                    result.push(SetOpInput {
+                        has_x,
+                        inverted,
+                        has_page: false,
+                    });
+                    let can_have_empty_page = has_x == inverted;
+                    if can_have_empty_page {
+                        result.push(SetOpInput {
+                            has_x,
+                            inverted,
+                            has_page: true,
+                        });
+                    }
+                }
+            }
+            result
+        }
+
+        fn to_set(&self, x: u32) -> IntSet<u32> {
+            let mut s = IntSet::<u32>::empty();
+            if self.inverted {
+                s.invert();
+            }
+            if self.has_page {
+                // Ensure a page exists for x.
+                if self.inverted {
+                    s.remove(x);
+                } else {
+                    s.insert(x);
+                }
+            }
+            if self.has_x {
+                s.insert(x);
+            } else {
+                s.remove(x);
+            }
+            s
+        }
+    }
+
+    fn set_operation_test_message(
+        a: &SetOpInput,
+        b: &SetOpInput,
+        op_name: &str,
+        should_contain_x: bool,
+    ) -> String {
+        format!(
+            "{}{}{} {} {}{}{} failed. {}",
+            if a.inverted { "i" } else { "" },
+            if a.has_page { "p" } else { "" },
+            if a.has_x { "13" } else { "" },
+            op_name,
+            if b.inverted { "i" } else { "" },
+            if b.has_page { "p" } else { "" },
+            if b.has_x { "13" } else { "" },
+            if should_contain_x {
+                "Result did not have 13."
+            } else {
+                "Result should not have 13."
+            }
+        )
+    }
+
+    fn check_union(a: &SetOpInput, b: &SetOpInput) {
+        let x = 13;
+        let mut set_a = a.to_set(x);
+        let set_b = b.to_set(x);
+
+        let should_contain_x = a.has_x || b.has_x;
+        set_a.union(&set_b);
+
+        assert_eq!(
+            set_a.contains(x),
+            should_contain_x,
+            "{}",
+            set_operation_test_message(a, b, "union", should_contain_x)
+        );
+    }
+
+    fn check_intersect(a: &SetOpInput, b: &SetOpInput) {
+        let x = 13;
+        let mut set_a = a.to_set(x);
+        let set_b = b.to_set(x);
+
+        let should_contain_x = a.has_x && b.has_x;
+        set_a.intersect(&set_b);
+
+        assert_eq!(
+            set_a.contains(x),
+            should_contain_x,
+            "{}",
+            set_operation_test_message(a, b, "intersect", should_contain_x)
+        );
+    }
+
+    #[test]
+    fn set_operations() {
+        for a in SetOpInput::get_all_inputs() {
+            for b in SetOpInput::get_all_inputs() {
+                check_union(&a, &b);
+                check_intersect(&a, &b);
+            }
+        }
+    }
+
     #[test]
     fn inverted() {
         let mut set = IntSet::<u32>::empty();
diff --git a/otexplorer/Cargo.toml b/otexplorer/Cargo.toml
index 56ac257f..88cc2019 100644
--- a/otexplorer/Cargo.toml
+++ b/otexplorer/Cargo.toml
@@ -8,8 +8,8 @@ publish = false
 
 [dependencies]
 xflags = "0.3.0"
-read-fonts = { path = "../read-fonts",version = "0.19.1" }
-font-types = { path = "../font-types",version = "0.5.3" }
+read-fonts = { path = "../read-fonts",version = "0.19.2" }
+font-types = { path = "../font-types",version = "0.5.4" }
 ansi_term = "0.12.1"
 atty = "0.2"
 
diff --git a/read-fonts/Cargo.toml b/read-fonts/Cargo.toml
index 660db612..4e8c5114 100644
--- a/read-fonts/Cargo.toml
+++ b/read-fonts/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "read-fonts"
-version = "0.19.1"
+version = "0.19.2"
 edition = "2021"
 license = "MIT/Apache-2.0"
 description = "Reading OpenType font files."
@@ -17,7 +17,7 @@ default = ["traversal"]
 serde = ["dep:serde", "font-types/serde"]
 
 [dependencies]
-font-types = { version = "0.5.3", path = "../font-types", features = ["bytemuck"] }
+font-types = { version = "0.5.4", path = "../font-types", features = ["bytemuck"] }
 serde = { version = "1.0", features = ["derive"], optional = true }
 bytemuck = { workspace = true }
 
diff --git a/read-fonts/generated/generated_gasp.rs b/read-fonts/generated/generated_gasp.rs
new file mode 100644
index 00000000..2639cd62
--- /dev/null
+++ b/read-fonts/generated/generated_gasp.rs
@@ -0,0 +1,457 @@
+// THIS FILE IS AUTOGENERATED.
+// Any changes to this file will be overwritten.
+// For more information about how codegen works, see font-codegen/README.md
+
+#[allow(unused_imports)]
+use crate::codegen_prelude::*;
+
+/// [gasp](https://learn.microsoft.com/en-us/typography/opentype/spec/gasp#gasp-table-formats)
+#[derive(Debug, Clone, Copy)]
+#[doc(hidden)]
+pub struct GaspMarker {
+    gasp_ranges_byte_len: usize,
+}
+
+impl GaspMarker {
+    fn version_byte_range(&self) -> Range<usize> {
+        let start = 0;
+        start..start + u16::RAW_BYTE_LEN
+    }
+    fn num_ranges_byte_range(&self) -> Range<usize> {
+        let start = self.version_byte_range().end;
+        start..start + u16::RAW_BYTE_LEN
+    }
+    fn gasp_ranges_byte_range(&self) -> Range<usize> {
+        let start = self.num_ranges_byte_range().end;
+        start..start + self.gasp_ranges_byte_len
+    }
+}
+
+impl<'a> FontRead<'a> for Gasp<'a> {
+    fn read(data: FontData<'a>) -> Result<Self, ReadError> {
+        let mut cursor = data.cursor();
+        cursor.advance::<u16>();
+        let num_ranges: u16 = cursor.read()?;
+        let gasp_ranges_byte_len = num_ranges as usize * GaspRange::RAW_BYTE_LEN;
+        cursor.advance_by(gasp_ranges_byte_len);
+        cursor.finish(GaspMarker {
+            gasp_ranges_byte_len,
+        })
+    }
+}
+
+/// [gasp](https://learn.microsoft.com/en-us/typography/opentype/spec/gasp#gasp-table-formats)
+pub type Gasp<'a> = TableRef<'a, GaspMarker>;
+
+impl<'a> Gasp<'a> {
+    /// Version number (set to 1)
+    pub fn version(&self) -> u16 {
+        let range = self.shape.version_byte_range();
+        self.data.read_at(range.start).unwrap()
+    }
+
+    /// Number of records to follow
+    pub fn num_ranges(&self) -> u16 {
+        let range = self.shape.num_ranges_byte_range();
+        self.data.read_at(range.start).unwrap()
+    }
+
+    /// Sorted by ppem
+    pub fn gasp_ranges(&self) -> &'a [GaspRange] {
+        let range = self.shape.gasp_ranges_byte_range();
+        self.data.read_array(range).unwrap()
+    }
+}
+
+#[cfg(feature = "traversal")]
+impl<'a> SomeTable<'a> for Gasp<'a> {
+    fn type_name(&self) -> &str {
+        "Gasp"
+    }
+    fn get_field(&self, idx: usize) -> Option<Field<'a>> {
+        match idx {
+            0usize => Some(Field::new("version", self.version())),
+            1usize => Some(Field::new("num_ranges", self.num_ranges())),
+            2usize => Some(Field::new(
+                "gasp_ranges",
+                traversal::FieldType::array_of_records(
+                    stringify!(GaspRange),
+                    self.gasp_ranges(),
+                    self.offset_data(),
+                ),
+            )),
+            _ => None,
+        }
+    }
+}
+
+#[cfg(feature = "traversal")]
+impl<'a> std::fmt::Debug for Gasp<'a> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        (self as &dyn SomeTable<'a>).fmt(f)
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Copy, bytemuck :: AnyBitPattern)]
+#[repr(C)]
+#[repr(packed)]
+pub struct GaspRange {
+    /// Upper limit of range, in PPEM
+    pub range_max_ppem: BigEndian<u16>,
+    /// Flags describing desired rasterizer behavior.
+    pub range_gasp_behavior: BigEndian<GaspRangeBehavior>,
+}
+
+impl GaspRange {
+    /// Upper limit of range, in PPEM
+    pub fn range_max_ppem(&self) -> u16 {
+        self.range_max_ppem.get()
+    }
+
+    /// Flags describing desired rasterizer behavior.
+    pub fn range_gasp_behavior(&self) -> GaspRangeBehavior {
+        self.range_gasp_behavior.get()
+    }
+}
+
+impl FixedSize for GaspRange {
+    const RAW_BYTE_LEN: usize = u16::RAW_BYTE_LEN + GaspRangeBehavior::RAW_BYTE_LEN;
+}
+
+#[cfg(feature = "traversal")]
+impl<'a> SomeRecord<'a> for GaspRange {
+    fn traverse(self, data: FontData<'a>) -> RecordResolver<'a> {
+        RecordResolver {
+            name: "GaspRange",
+            get_field: Box::new(move |idx, _data| match idx {
+                0usize => Some(Field::new("range_max_ppem", self.range_max_ppem())),
+                1usize => Some(Field::new(
+                    "range_gasp_behavior",
+                    self.range_gasp_behavior(),
+                )),
+                _ => None,
+            }),
+            data,
+        }
+    }
+}
+
+#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, bytemuck :: AnyBitPattern)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(transparent)]
+pub struct GaspRangeBehavior {
+    bits: u16,
+}
+
+impl GaspRangeBehavior {
+    /// Use gridfitting
+    pub const GASP_GRIDFIT: Self = Self { bits: 0x0001 };
+
+    /// Use grayscale rendering
+    pub const GASP_DOGRAY: Self = Self { bits: 0x0002 };
+
+    /// Use gridfitting with ClearType symmetric smoothing Only
+    /// supported in version 1 'gasp'
+    pub const GASP_SYMMETRIC_GRIDFIT: Self = Self { bits: 0x0004 };
+
+    /// Use smoothing along multiple axes with ClearType® Only
+    /// supported in version 1 'gasp'
+    pub const GASP_SYMMETRIC_SMOOTHING: Self = Self { bits: 0x0008 };
+}
+
+impl GaspRangeBehavior {
+    ///  Returns an empty set of flags.
+    #[inline]
+    pub const fn empty() -> Self {
+        Self { bits: 0 }
+    }
+
+    /// Returns the set containing all flags.
+    #[inline]
+    pub const fn all() -> Self {
+        Self {
+            bits: Self::GASP_GRIDFIT.bits
+                | Self::GASP_DOGRAY.bits
+                | Self::GASP_SYMMETRIC_GRIDFIT.bits
+                | Self::GASP_SYMMETRIC_SMOOTHING.bits,
+        }
+    }
+
+    /// Returns the raw value of the flags currently stored.
+    #[inline]
+    pub const fn bits(&self) -> u16 {
+        self.bits
+    }
+
+    /// Convert from underlying bit representation, unless that
+    /// representation contains bits that do not correspond to a flag.
+    #[inline]
+    pub const fn from_bits(bits: u16) -> Option<Self> {
+        if (bits & !Self::all().bits()) == 0 {
+            Some(Self { bits })
+        } else {
+            None
+        }
+    }
+
+    /// Convert from underlying bit representation, dropping any bits
+    /// that do not correspond to flags.
+    #[inline]
+    pub const fn from_bits_truncate(bits: u16) -> Self {
+        Self {
+            bits: bits & Self::all().bits,
+        }
+    }
+
+    /// Returns `true` if no flags are currently stored.
+    #[inline]
+    pub const fn is_empty(&self) -> bool {
+        self.bits() == Self::empty().bits()
+    }
+
+    /// Returns `true` if there are flags common to both `self` and `other`.
+    #[inline]
+    pub const fn intersects(&self, other: Self) -> bool {
+        !(Self {
+            bits: self.bits & other.bits,
+        })
+        .is_empty()
+    }
+
+    /// Returns `true` if all of the flags in `other` are contained within `self`.
+    #[inline]
+    pub const fn contains(&self, other: Self) -> bool {
+        (self.bits & other.bits) == other.bits
+    }
+
+    /// Inserts the specified flags in-place.
+    #[inline]
+    pub fn insert(&mut self, other: Self) {
+        self.bits |= other.bits;
+    }
+
+    /// Removes the specified flags in-place.
+    #[inline]
+    pub fn remove(&mut self, other: Self) {
+        self.bits &= !other.bits;
+    }
+
+    /// Toggles the specified flags in-place.
+    #[inline]
+    pub fn toggle(&mut self, other: Self) {
+        self.bits ^= other.bits;
+    }
+
+    /// Returns the intersection between the flags in `self` and
+    /// `other`.
+    ///
+    /// Specifically, the returned set contains only the flags which are
+    /// present in *both* `self` *and* `other`.
+    ///
+    /// This is equivalent to using the `&` operator (e.g.
+    /// [`ops::BitAnd`]), as in `flags & other`.
+    ///
+    /// [`ops::BitAnd`]: https://doc.rust-lang.org/std/ops/trait.BitAnd.html
+    #[inline]
+    #[must_use]
+    pub const fn intersection(self, other: Self) -> Self {
+        Self {
+            bits: self.bits & other.bits,
+        }
+    }
+
+    /// Returns the union of between the flags in `self` and `other`.
+    ///
+    /// Specifically, the returned set contains all flags which are
+    /// present in *either* `self` *or* `other`, including any which are
+    /// present in both.
+    ///
+    /// This is equivalent to using the `|` operator (e.g.
+    /// [`ops::BitOr`]), as in `flags | other`.
+    ///
+    /// [`ops::BitOr`]: https://doc.rust-lang.org/std/ops/trait.BitOr.html
+    #[inline]
+    #[must_use]
+    pub const fn union(self, other: Self) -> Self {
+        Self {
+            bits: self.bits | other.bits,
+        }
+    }
+
+    /// Returns the difference between the flags in `self` and `other`.
+    ///
+    /// Specifically, the returned set contains all flags present in
+    /// `self`, except for the ones present in `other`.
+    ///
+    /// It is also conceptually equivalent to the "bit-clear" operation:
+    /// `flags & !other` (and this syntax is also supported).
+    ///
+    /// This is equivalent to using the `-` operator (e.g.
+    /// [`ops::Sub`]), as in `flags - other`.
+    ///
+    /// [`ops::Sub`]: https://doc.rust-lang.org/std/ops/trait.Sub.html
+    #[inline]
+    #[must_use]
+    pub const fn difference(self, other: Self) -> Self {
+        Self {
+            bits: self.bits & !other.bits,
+        }
+    }
+}
+
+impl std::ops::BitOr for GaspRangeBehavior {
+    type Output = Self;
+
+    /// Returns the union of the two sets of flags.
+    #[inline]
+    fn bitor(self, other: GaspRangeBehavior) -> Self {
+        Self {
+            bits: self.bits | other.bits,
+        }
+    }
+}
+
+impl std::ops::BitOrAssign for GaspRangeBehavior {
+    /// Adds the set of flags.
+    #[inline]
+    fn bitor_assign(&mut self, other: Self) {
+        self.bits |= other.bits;
+    }
+}
+
+impl std::ops::BitXor for GaspRangeBehavior {
+    type Output = Self;
+
+    /// Returns the left flags, but with all the right flags toggled.
+    #[inline]
+    fn bitxor(self, other: Self) -> Self {
+        Self {
+            bits: self.bits ^ other.bits,
+        }
+    }
+}
+
+impl std::ops::BitXorAssign for GaspRangeBehavior {
+    /// Toggles the set of flags.
+    #[inline]
+    fn bitxor_assign(&mut self, other: Self) {
+        self.bits ^= other.bits;
+    }
+}
+
+impl std::ops::BitAnd for GaspRangeBehavior {
+    type Output = Self;
+
+    /// Returns the intersection between the two sets of flags.
+    #[inline]
+    fn bitand(self, other: Self) -> Self {
+        Self {
+            bits: self.bits & other.bits,
+        }
+    }
+}
+
+impl std::ops::BitAndAssign for GaspRangeBehavior {
+    /// Disables all flags disabled in the set.
+    #[inline]
+    fn bitand_assign(&mut self, other: Self) {
+        self.bits &= other.bits;
+    }
+}
+
+impl std::ops::Sub for GaspRangeBehavior {
+    type Output = Self;
+
+    /// Returns the set difference of the two sets of flags.
+    #[inline]
+    fn sub(self, other: Self) -> Self {
+        Self {
+            bits: self.bits & !other.bits,
+        }
+    }
+}
+
+impl std::ops::SubAssign for GaspRangeBehavior {
+    /// Disables all flags enabled in the set.
+    #[inline]
+    fn sub_assign(&mut self, other: Self) {
+        self.bits &= !other.bits;
+    }
+}
+
+impl std::ops::Not for GaspRangeBehavior {
+    type Output = Self;
+
+    /// Returns the complement of this set of flags.
+    #[inline]
+    fn not(self) -> Self {
+        Self { bits: !self.bits } & Self::all()
+    }
+}
+
+impl std::fmt::Debug for GaspRangeBehavior {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        let members: &[(&str, Self)] = &[
+            ("GASP_GRIDFIT", Self::GASP_GRIDFIT),
+            ("GASP_DOGRAY", Self::GASP_DOGRAY),
+            ("GASP_SYMMETRIC_GRIDFIT", Self::GASP_SYMMETRIC_GRIDFIT),
+            ("GASP_SYMMETRIC_SMOOTHING", Self::GASP_SYMMETRIC_SMOOTHING),
+        ];
+        let mut first = true;
+        for (name, value) in members {
+            if self.contains(*value) {
+                if !first {
+                    f.write_str(" | ")?;
+                }
+                first = false;
+                f.write_str(name)?;
+            }
+        }
+        if first {
+            f.write_str("(empty)")?;
+        }
+        Ok(())
+    }
+}
+
+impl std::fmt::Binary for GaspRangeBehavior {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        std::fmt::Binary::fmt(&self.bits, f)
+    }
+}
+
+impl std::fmt::Octal for GaspRangeBehavior {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        std::fmt::Octal::fmt(&self.bits, f)
+    }
+}
+
+impl std::fmt::LowerHex for GaspRangeBehavior {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        std::fmt::LowerHex::fmt(&self.bits, f)
+    }
+}
+
+impl std::fmt::UpperHex for GaspRangeBehavior {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        std::fmt::UpperHex::fmt(&self.bits, f)
+    }
+}
+
+impl font_types::Scalar for GaspRangeBehavior {
+    type Raw = <u16 as font_types::Scalar>::Raw;
+    fn to_raw(self) -> Self::Raw {
+        self.bits().to_raw()
+    }
+    fn from_raw(raw: Self::Raw) -> Self {
+        let t = <u16>::from_raw(raw);
+        Self::from_bits_truncate(t)
+    }
+}
+
+#[cfg(feature = "traversal")]
+impl<'a> From<GaspRangeBehavior> for FieldType<'a> {
+    fn from(src: GaspRangeBehavior) -> FieldType<'a> {
+        src.bits().into()
+    }
+}
diff --git a/read-fonts/src/tables.rs b/read-fonts/src/tables.rs
index c9061594..bb089a24 100644
--- a/read-fonts/src/tables.rs
+++ b/read-fonts/src/tables.rs
@@ -14,6 +14,7 @@ pub mod cvar;
 pub mod ebdt;
 pub mod eblc;
 pub mod fvar;
+pub mod gasp;
 pub mod gdef;
 pub mod glyf;
 pub mod gpos;
diff --git a/read-fonts/src/tables/gasp.rs b/read-fonts/src/tables/gasp.rs
new file mode 100644
index 00000000..01b0af40
--- /dev/null
+++ b/read-fonts/src/tables/gasp.rs
@@ -0,0 +1,44 @@
+//! The [gasp](https://learn.microsoft.com/en-us/typography/opentype/spec/gasp) table
+
+include!("../../generated/generated_gasp.rs");
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::test_helpers::BeBuffer;
+
+    #[test]
+    fn smoke_test() {
+        let buf = BeBuffer::new()
+            .push(1u16) // version
+            .push(2u16) // number of records
+            .push(404u16) // record 1 ppem
+            .push(GaspRangeBehavior::GASP_GRIDFIT | GaspRangeBehavior::GASP_DOGRAY)
+            .push(u16::MAX)
+            .push(
+                GaspRangeBehavior::GASP_SYMMETRIC_GRIDFIT
+                    | GaspRangeBehavior::GASP_SYMMETRIC_SMOOTHING,
+            );
+
+        let gasp = Gasp::read(buf.font_data()).unwrap();
+        assert_eq!(gasp.version(), 1);
+        assert_eq!(
+            gasp.gasp_ranges()[0],
+            GaspRange {
+                range_max_ppem: 404.into(),
+                range_gasp_behavior: (GaspRangeBehavior::GASP_GRIDFIT
+                    | GaspRangeBehavior::GASP_DOGRAY)
+                    .into(),
+            }
+        );
+        assert_eq!(
+            gasp.gasp_ranges()[1],
+            GaspRange {
+                range_max_ppem: u16::MAX.into(),
+                range_gasp_behavior: (GaspRangeBehavior::GASP_SYMMETRIC_GRIDFIT
+                    | GaspRangeBehavior::GASP_SYMMETRIC_SMOOTHING)
+                    .into(),
+            }
+        );
+    }
+}
diff --git a/resources/codegen_inputs/gasp.rs b/resources/codegen_inputs/gasp.rs
new file mode 100644
index 00000000..214b8fdb
--- /dev/null
+++ b/resources/codegen_inputs/gasp.rs
@@ -0,0 +1,33 @@
+#![parse_module(read_fonts::tables::gasp)]
+
+/// [gasp](https://learn.microsoft.com/en-us/typography/opentype/spec/gasp#gasp-table-formats)
+table Gasp {
+    /// Version number (set to 1)
+    version: u16,
+    /// Number of records to follow
+    num_ranges: u16,
+    /// Sorted by ppem
+    #[count($num_ranges)]
+    gasp_ranges: [GaspRange],
+}
+
+record GaspRange {
+    /// Upper limit of range, in PPEM
+    range_max_ppem: u16,
+    /// Flags describing desired rasterizer behavior.
+    range_gasp_behavior: GaspRangeBehavior,
+}
+
+flags u16 GaspRangeBehavior {
+    /// Use gridfitting
+    GASP_GRIDFIT = 0x0001,
+    /// Use grayscale rendering
+    GASP_DOGRAY = 0x0002,
+    /// Use gridfitting with ClearType symmetric smoothing Only 
+    /// supported in version 1 'gasp'
+    GASP_SYMMETRIC_GRIDFIT = 0x0004,
+    /// Use smoothing along multiple axes with ClearType® Only 
+    /// supported in version 1 'gasp'
+    GASP_SYMMETRIC_SMOOTHING = 0x0008,
+}
+
diff --git a/resources/codegen_plan.toml b/resources/codegen_plan.toml
index b8bdb826..c638c077 100644
--- a/resources/codegen_plan.toml
+++ b/resources/codegen_plan.toml
@@ -333,6 +333,11 @@ mode = "parse"
 source = "resources/codegen_inputs/cbdt.rs"
 target = "read-fonts/generated/generated_cbdt.rs"
 
+[[generate]]
+mode = "parse"
+source = "resources/codegen_inputs/gasp.rs"
+target = "read-fonts/generated/generated_gasp.rs"
+
 # modules just used for testing
 [[generate]]
 mode = "parse"
diff --git a/skrifa/Cargo.toml b/skrifa/Cargo.toml
index f16e0100..789aec27 100644
--- a/skrifa/Cargo.toml
+++ b/skrifa/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "skrifa"
-version = "0.19.1"
+version = "0.19.2"
 edition = "2021"
 license = "MIT/Apache-2.0"
 description = "Metadata reader and glyph scaler for OpenType fonts."
@@ -15,13 +15,13 @@ traversal = ["std", "read-fonts/traversal"]
 libm = ["dep:core_maths"]
 
 [dependencies]
-read-fonts = { version = "0.19.1", path = "../read-fonts", default-features = false }
+read-fonts = { version = "0.19.2", path = "../read-fonts", default-features = false }
 core_maths = { version = "0.1", optional = true }
 bytemuck = { workspace = true }
 
 [dev-dependencies]
 font-test-data = { path = "../font-test-data" }
-read-fonts = { version = "0.19.1", path = "../read-fonts", features = [
+read-fonts = { version = "0.19.2", path = "../read-fonts", features = [
     "scaler_test",
     "serde",
 ] }
diff --git a/write-fonts/Cargo.toml b/write-fonts/Cargo.toml
index 89865d46..1bd8ff8d 100644
--- a/write-fonts/Cargo.toml
+++ b/write-fonts/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "write-fonts"
-version = "0.26.0"
+version = "0.26.1"
 edition = "2021"
 license = "MIT/Apache-2.0"
 description = "Writing font files."
@@ -14,8 +14,8 @@ read = []
 serde = ["dep:serde", "font-types/serde", "read-fonts/serde"]
 
 [dependencies]
-font-types = { version = "0.5.3", path = "../font-types" }
-read-fonts = { version = "0.19.1", path = "../read-fonts" }
+font-types = { version = "0.5.4", path = "../font-types" }
+read-fonts = { version = "0.19.2", path = "../read-fonts" }
 log = "0.4"
 kurbo.workspace = true
 dot2 = { version = "1.0", optional = true }
@@ -26,7 +26,7 @@ indexmap = "2.0"
 diff = "0.1.12"
 ansi_term = "0.12.1"
 font-test-data = { path = "../font-test-data" }
-read-fonts = { version = "0.19.1", path = "../read-fonts", features = [
+read-fonts = { version = "0.19.2", path = "../read-fonts", features = [
   "codegen_test",
 ] }
 rstest = "0.18.0"


Test patch:
diff --git a/font-test-data/Cargo.toml b/font-test-data/Cargo.toml
index 5835b2bc..5193fa09 100644
--- a/font-test-data/Cargo.toml
+++ b/font-test-data/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "font-test-data"
-version = "0.1.4"
+version = "0.1.5"
 edition = "2021"
 license = "MIT/Apache-2.0"
 description = "Test data for the fontations crates"