diff --git a/CHANGELOG.md b/CHANGELOG.md
index e819a80..7d6b48a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,310 @@ GitHub issues and pull requests. sigilbuzz is pre-1.0, so a minor release may ch
the API. See [docs/STABILITY.md](docs/STABILITY.md) for what is covered by the
stability commitment.
+## 0.24.0 (2026-10-05)
+
+The crates in this release: `sigilbuzz` 0.24.0, `sigilbuzz-capi` 0.5.0, `sigilbuzz-cli`
+0.3.0, `sigilbuzz-gpu` 0.3.0, `sigilbuzz-hyphen` 0.3.0, `sigilbuzz-paint` 0.4.0,
+`sigilbuzz-pdf` 0.4.0, `sigilbuzz-render` 0.11.0, `sigilbuzz-subset` 0.14.0,
+`sigilbuzz-svg` 0.4.0, `sigilbuzz-text-layout` 0.3.0, and `sigilbuzz-woff` 0.3.3.
+Companion crates that take `sigilbuzz` types in their API move with the core crate's
+minor version. `sigilbuzz-woff` exposes no `sigilbuzz` types and takes a patch release
+to require `sigilbuzz` 0.24.0. Only `sigilbuzz-paint`, `sigilbuzz-render` and
+`sigilbuzz-subset` change code of their own.
+
+Changes that can break a build, or change output with default settings, each have a
+bullet under Changed: the default cluster level, `Font::coords`,
+`MultiVarStore::resolve_deltas`, `GeneralCategoryClass`, the core crate's first `unsafe`
+code and its need for compare-and-swap atomics, VARC tables that do not parse, the JPEG
+decoder's limits, CFF2 instance output, and the work budgets that make some hostile
+fonts fail.
+
+Changed:
+
+- The default cluster level is now `MonotoneGraphemes`, HarfBuzz's default (#288, #292).
+ `ClusterLevel::default()`, and so every new `Buffer`, was `MonotoneCharacters`. Under
+ the defaults, a combining mark, a variation selector, the second regional indicator of
+ a flag, the rest of a ZWJ emoji sequence and the other characters a grapheme merges
+ now share the cluster of the character they continue, as in HarfBuzz. Glyphs and
+ positions do not change. Code that expects a cluster per character calls
+ `buffer.set_cluster_level(ClusterLevel::MonotoneCharacters)`. The C API already
+ defaulted to `HB_BUFFER_CLUSTER_LEVEL_MONOTONE_GRAPHEMES` and is unchanged. On 554
+ cases built from known consumers' fonts and strings, clusters under the defaults now
+ match HarfBuzz 14.5.0 in all 554.
+- `Font::coords` is now `pub fn coords(&self) -> &[f32]`: the slice borrows the font
+ instead of living for `'a`, and the method is no longer a `const fn` (#289, #295). A
+ font built with `Font::with_variations` owns its coords. Code that keeps the slice
+ after dropping the `Font` copies it first. `Font::new` is still `const`.
+- `MultiVarStore::resolve_deltas` keeps its signature but reads a delta set as one tuple
+ per region, region after region, as the spec, fontTools and HarfBuzz lay it out, and
+ ignores values past the last tuple (#291, #293). It read the values of each output in
+ turn, so a set varying two or more values over two or more regions resolved wrong. A
+ delta set that ends before it fills its tuples now adds the values it holds, as
+ HarfBuzz reads it, where it added nothing, and a run cut short ends its tuple after
+ its control byte (#296, #298). Semver tools cannot see this: a caller that encoded
+ delta sets value by value gets different numbers. VARC components of a crafted font
+ with short delta sets sat up to 100 units from HarfBuzz's.
+- `unicode::general_category::GeneralCategoryClass` has a `Symbol` class (Sc, Sk, Sm,
+ So) and is `#[non_exhaustive]`, so a `match` on it outside the crate needs a wildcard
+ arm (#288, #292). `general_category_class` returns `Some(Symbol)` for the symbols,
+ which were `None`. The table grows by 242 ranges.
+- The core crate has `unsafe` code for the first time: `OnceBox` in `src/sync.rs`, about
+ 40 lines with `SAFETY` comments, modeled on `once_cell::race::OnceBox` (#289, #295).
+ It is an `AtomicPtr` set once by compare-and-swap, through which a `Font` builds its
+ caches without locks. The crate needs a target with pointer-width atomics and
+ compare-and-swap (`cfg(target_has_atomic = "ptr")`), which `alloc::sync::Arc` already
+ needed, so `thumbv6m-none-eabi` does not build, as in 0.23 (#297, #301). The crate
+ still has no dependencies and builds under `no_std`, and `Font`, `Face` and `Blob`
+ stay `Send + Sync`.
+- A `VARC` table that does not parse, such as one in the MultiItemVariationStore layout
+ HarfBuzz adopted after 14.5.0, counts as absent, as HarfBuzz 14.5.0's sanitizer drops
+ it (#296, #300). Outlines, `GlyphOutlines`, and the glyph extents shaping reads for
+ fallback mark positioning and vertical origins come from `glyf`, CFF or CFF2. Every
+ glyph of such a font used to fail to draw, shaping failed on fallback-positioned
+ marks, and vertical origins fell back to the ascender. `Face::varc` still reports the
+ error. Reading the new layout is #299.
+- `script_of` returns the bucket of a character's Unicode Script property for every
+ bucket (#288, #292). The older buckets, Latin through Modi, covered the Unicode blocks
+ of their scripts, so letters outside those blocks were `Other`: Latin Extended
+ Additional, Arabic Extended-A, -B and -C, the Arabic mathematical letters, the CJK
+ extensions past B and the compatibility ideographs, the Mongolian Supplement,
+ Devanagari Extended and others. They now take their script's bucket. The Common and
+ Inherited characters inside the blocks took the block's bucket and are now `Other`.
+ These include digits, punctuation, the space, the tatweel, the dandas and combining
+ marks, so `script_of('0')` was `Latin`. The Coptic letters of the Greek and Coptic
+ block are `Other`, not `Greek`.
+- Script runs (`Buffer::script_runs` and the segmentation of `shape`) and
+ `Buffer::guess_segment_properties` skip unassigned and private-use characters, as well
+ as Common and Inherited ones, as `hb_buffer_guess_segment_properties` does (#288,
+ #292). A private-use icon inside Latin text stays in the Latin run. The ordinal
+ indicators U+00AA and U+00BA count as Latin letters, so inside text of another script
+ they start a Latin run when the buffer has no script set.
+- The vertical default for Mongolian, used when no direction is set, applies to text
+ that starts with a Mongolian character of the Mongolian block (#288, #292). Text that
+ starts with a quotation mark, the Mongolian comma, or a letter of another script stays
+ horizontal, as HarfBuzz lays out all text with no direction. Set `Direction::Ttb` for
+ vertical Mongolian.
+- A `Font` keeps what HarfBuzz keeps per face and font, from its second shaping call on:
+ per-lookup glyph digests, so lookups and subtables that cannot apply are skipped
+ unparsed; the language system each script, language and FeatureVariations record
+ resolves to; the merged lookups of each shaping stage; and per-glyph vertical origins
+ and advances (#289, #295). One Noto Sans KR syllable shapes in about 4 us instead of
+ 62, one Segoe UI character in 3 instead of 9, one Devanagari character in 13 instead
+ of 400. Clones made after a font's first call share the caches, which are thread-safe
+ without locks. A font's first call builds none of it, and subtables are parsed only
+ when a glyph reaches them there too. Output does not depend on the caches.
+- What a `Font` keeps is bounded (#295, #297, #301). For each of GSUB and GPOS: at most
+ twice the table's length plus 64 Ki subtable digests of 24 bytes, however many lookups
+ the table lists, with the lookup indices that name one lookup sharing its digests; a
+ lookup the budget cannot pay for keeps no digest and is only skipped less. The
+ language systems of at most 16 combinations of script, language and FeatureVariations
+ record per table; a run with another combination looks its features up in the font as
+ before. Three per-glyph caches of at most 4,096 four-byte entries each, whatever the
+ glyph count. A lookup of thousands of subtables costs about what it did in 0.23.1,
+ within about 10%.
+- A variable `Font` keeps each glyph's varied advance, from `HVAR`, `VVAR` or phantom
+ points, from its second call on, as HarfBuzz keeps an advance cache, so a run no
+ longer walks the variation store for every glyph of every call (#297, #301). 100
+ characters of Source Serif 4 VF at wght 700 shape left to right in 62 us instead of
+ 119 us in 0.23.0, and Hahmlet at wght 700 in 22 us instead of 90 us.
+- Repeated top-to-bottom runs on one `Font` are faster than in 0.23.0: a CFF run about
+ as fast (Source Code Pro, 100 characters: 9.5 to 8.6 us), CFF2 and varied `glyf` runs
+ two to three times as fast (Source Serif 4 VF at wght 700: 77 to 24 us; Hahmlet at
+ wght 700: 67 to 20 us) (#289, #295, #297, #301). A font's first top-to-bottom call is
+ slower than in 0.23.0 for fonts without `VORG`, whose vertical origins come from glyph
+ extents since 0.23.1.
+- The Indic, Khmer, Myanmar and USE syllable grammars are compiled once per process
+ instead of once per call (#289, #295).
+- `Face::glyph_outline_at_coords` and `GlyphOutlines` draw a VARC glyph in one walk that
+ resolves each composite once per set of coords, composes transforms top down in
+ HarfBuzz's order, and draws each leaf glyph once (#291, #293, #296, #298, #300). The
+ walk shares one budget across every composite the glyph reaches: 2^20 units of work
+ for component records, axis values, condition offsets, coord vectors, region axes and
+ deltas, and 65,536 condition visits. Its leaves, the glyphs it draws from `glyf` or
+ CFF, share HarfBuzz's 2^24-unit glyph budget for their `gvar` tuple work, glyph
+ visits, points and charstring operations. Past a budget, the composite being read and
+ every later one ends and a leaf draws nothing, as in HarfBuzz, and the walk reads no
+ more component records than it can still draw. Every crafted glyph tested draws in
+ under 24 ms. The 2,048-component, 2^20-op and 64-level caps still fail the glyph with
+ `Malformed`.
+- `sigilbuzz-render` 0.11.0: `decode_jpeg` rejects a stream with a second frame (as
+ libjpeg does), a frame whose blocks outnumber eight per byte of entropy-coded scan
+ data (COM, APPn and other padding no longer count), and a frame past 2^22 pixels
+ (2048 x 2048), each as `RenderError::BadJpeg` (#291, #293). The scans of a stream may
+ visit at most 1024 coefficients per block of its frame, more than any valid script
+ needs. JPEG data in fonts is sbix glyph images, far below these limits.
+- `sigilbuzz-subset` 0.14.0: a full CFF2 instance rounds each blended value to a whole
+ unit, as HarfBuzz's flattener does, and starts each charstring at its Private DICT's
+ `vsindex` (#290, #294). Its charstrings equal HarfBuzz's, and Source Serif 4 VF at
+ wght 700 drops from 1.07 MB to 0.65 MB (its `CFF2` from 614 KB to 203 KB, HarfBuzz
+ 203 KB).
+- `sigilbuzz-subset`: a CFF2 instance, full or partial, recomputes its left side
+ bearings, `head` box and `hhea` extremes from its outlines, as HarfBuzz does (#290,
+ #294). They kept the source's, so 1,375 of Source Serif 4 VF's 1,464 side bearings
+ were off at wght 700. Measuring takes time: an instance of Noto Sans KR VF (24,731
+ glyphs) takes 0.26 to 0.34 s instead of 0.16 to 0.25 s.
+- `sigilbuzz-subset`: the CFF and CFF2 subsetter runs each kept glyph through its
+ subroutine calls, as HarfBuzz's subsetter does, and fails a charstring whose
+ subroutines nest more than 10 deep or reach themselves, that stacks more than 513
+ operands, or that takes more than 64 tokens per byte of the table plus 2^22, a `blend`
+ charging a token per value it leaves (#290, #294). Its memory follows the subroutines
+ the kept glyphs reach, and the Font DICTs of a CID-keyed font that name one Private
+ DICT share one read of it. The public per-body helpers `scan_subr_calls`,
+ `compute_kept_subrs` and `renumber_charstring` are unchanged.
+- `sigilbuzz-subset`: a full CFF2 instance fails a charstring that stacks more than 513
+ operands, the CFF2 limit, and its bake charges a token per value each `blend` leaves
+ (#290, #294). Seven crafted fonts that 0.23.1 instanced now fail, four on the budget
+ and three past 513 operands; HarfBuzz's instancer drops the `CFF2` table of six of
+ them. Crafted fonts that repeat blends without regions fail the budget in under a
+ second, and real fonts spend under 2% of it.
+- `sigilbuzz-subset`: a renumbered subroutine call that does not fit its push is written
+ in its shortest form instead of failing the subset with "cannot pad operand to
+ original width", and a CFF2 subset no longer keeps every subroutine when that happens
+ (#290, #294). The Source Sans 3 VF fixture's "Hello" subset drops from 12,368 to 2,588
+ bytes.
+- `sigilbuzz-subset`: `subset` leaves out a `VARC` that does not parse, with a warning,
+ and the glyphs keep their `glyf` or CFF outlines, as the face reads them (#296, #300).
+ The whole subset failed before. The output equals the subset of the font without
+ `VARC`.
+- `sigilbuzz-cli` 0.3.0: `sigilbuzz shape` builds its buffer at the default cluster
+ level, so its `cluster=` values change as described above and now match `hb-shape`
+ (#288, #292).
+
+Added:
+
+- `Font::with_variations` binds a variable instance from user-space axis values, as
+ HarfBuzz's `hb_font_set_variations` does: each `(tag, value)` sets every axis with
+ that tag, a later pair wins, unknown tags are ignored, and the values are clamped and
+ go through `fvar` and `avar` with HarfBuzz's rounding (#289, #295). The font owns the
+ coords it computes. On 4,200 random settings over every variable font tested, the
+ coords equal HarfBuzz's.
+- `Face::glyph_outlines` returns a `GlyphOutlines` that draws many glyphs at one
+ instance with the outline tables read once, the `CFF2` table with its variation store,
+ Private DICTs and region scalars included (#289, #295). `outline` returns each glyph
+ as `Face::glyph_outline_at_coords` draws it, errors included, and `draw` streams into
+ any `OutlineSink`. Against `glyph_outline_at_coords`, a glyph of Source Serif 4 VF
+ takes about 3.7 us instead of 5.1, and one of Noto Sans KR VF 2.1 us instead of 3.0.
+- `Varc::composite_with_font_coords` resolves a VARC glyph reached through another
+ composite: its own coords and the font's are given separately, so
+ `RESET_UNSPECIFIED_AXES` components start from the font's coords (#291, #293). It
+ charges its work to the 2^20-unit budget, and it gives no components for a glyph the
+ coverage names past the end of the glyph records, which HarfBuzz draws as nothing
+ (#296, #298).
+- `sigilbuzz-subset`: `instance_user` instances a variable font at axis values in user
+ units, given by axis tag, as HarfBuzz's instancer takes them (#290, #294). `AxisLimit`
+ pins an axis at a value (clamped to its range) or at its default, keeps it, or takes a
+ HarfBuzz-style range; a range that would narrow an axis or move its default returns
+ `SubsetError::Unsupported`. Axes the input does not name are pinned at their defaults,
+ or kept with `keep_unnamed_axes`. Build the input with `UserInstanceInput::new()` and
+ its `with_` methods; both types are `#[non_exhaustive]`. Callers no longer normalize
+ each axis with `VariationAxis::normalize`, and the output equals `instance` at the
+ same normalized coordinates.
+
+Fixed:
+
+- Text whose first letter sits outside the blocks of the older script buckets shapes
+ with its script, as in HarfBuzz (#288, #292). It shaped as `Other` under `DFLT`.
+ Roboto "ỷ fi" lost the fi ligature ([1139, 4, 74, 77], where HarfBuzz gives
+ [1139, 4, 444]), the Windows 11 sans "Ựa" lost its kerning, Arabic Extended-A shaped
+ left to right with no joining in all 7 fonts tested (Tahoma U+08A0 U+0628 U+0644), and
+ a Mongolian Supplement birga lost the joining forms of the letters after it. Over
+ 11,043 such texts in 30 fonts, mismatches against HarfBuzz 14.5.0 fall from 1,258 to 8
+ (Coptic, which has no bucket).
+- Text with no script-bearing character shapes left to right, as HarfBuzz's invalid
+ script does (#288, #292). Leading Arabic marks before a tatweel were reversed: Arial
+ U+064F U+064B U+0640 gave [757, 753, 752], where HarfBuzz gives [753, 757, 752].
+- The `stch` stretch (U+070F SYRIAC ABBREVIATION MARK) counts symbols (General_Category
+ Sc, Sk, Sm and So) into the word it fills, as HarfBuzz does (#288, #292). A symbol
+ ended the word, so the stretch came out short.
+- A feature list that names a tag more than once takes its last value, as HarfBuzz does:
+ `liga=0,liga=1` ligates and `kern=0,kern=1` kerns (#289, #295). Any zero entry used to
+ turn the feature off.
+- `Face::glyph_points`, which `kerx` anchors read, gives a font without `vmtx`
+ HarfBuzz's vertical phantom points, (0, yMax) and (0, yMax - unitsPerEm), as the
+ outline and bounds methods do; they were (0, 0) (#289, #295).
+- VARC records read as HarfBuzz reads them (#291, #293): ScaleY defaults to ScaleX, each
+ reserved flag bit skips one uint32var instead of ending the glyph, the axis-values
+ variation index is read without HAVE_AXES, an axis index past 4096 is ignored, an
+ out-of-range axis indices index names no axes, a 24-bit glyph id past 65535 draws
+ nothing, and the sparse region falloff follows HarfBuzz for zero peaks and invalid
+ triples.
+- VARC component axis values plus their deltas round to F2DOT14 (halves up) before they
+ become the child's coords, as in HarfBuzz (#291, #293).
+- VARC deltas apply at the default instance as HarfBuzz applies them: a variable font
+ holds a zero per axis there, so deltas from a region that constrains no axis apply,
+ and a font without `fvar` applies none (#291, #293, #296, #298).
+- VARC conditions gate components, with all five ConditionList formats and a 64-level
+ nesting limit, and a `RESET_UNSPECIFIED_AXES` component starts from the font's coords
+ at any depth, through `Face::glyph_outline_at_coords` and `GlyphOutlines` (#291, #293,
+ #296, #298). Conditions were read and ignored, and the reset was ignored.
+- A VARC component that names its own glyph draws that glyph's `glyf` or CFF outline, as
+ HarfBuzz does, instead of failing the glyph at the 64-level depth cap (#296, #298,
+ #300). Cycles of up to 32 glyphs stop where HarfBuzz's decycler stops them; a longer
+ cycle still fails at the 64-level cap. A glyph the coverage names past the glyph
+ records draws nothing, as in HarfBuzz.
+- VARC component rotations and skews are composed as HarfBuzz's `to_transform` composes
+ them, with `sinf`, `cosf` and `tanf` precision (#296, #300). A rotation past about 90
+ degrees was off: a box rotated 180 degrees about a center 500 units away landed
+ 42 units from HarfBuzz's, and HarfBuzz's `varc-6868` test font drew 2 units off.
+- `morx` contextual subtables read their substitution table as the spec's unsized array
+ of offsets, so contextual substitutions apply; at the end of text they substitute only
+ after a mark was set, and then on the last glyph, and the mark starts on the first
+ glyph, as in HarfBuzz (#291, #293).
+- `morx` ligatures form as in HarfBuzz (#291, #293): a component set twice (DontAdvance)
+ counts once, so the ligature or a neighbour is no longer deleted; the ligature stays
+ on the component stack for cascading ligatures; Store or Last stores; and deleted
+ components stay in the run, in the deleted-glyph class, until every chain has run.
+- `morx` substitutions to glyph 1 apply; the lookup reader's out-of-bounds class read as
+ "no substitution" (#291, #293).
+- `sigilbuzz-render`: progressive JPEGs decode (#291, #293). The AC scans stored each
+ coefficient at its natural position and the IDCT stage mapped it through the zig-zag
+ table a second time, so any progressive sbix JPEG with AC energy decoded to the wrong
+ picture. One-component scans walked the MCU-padded block grid instead of the
+ component's own blocks (T.81 A.2.2), which misread frames that end partway through an
+ MCU, and a one-component baseline frame that declares 2x2 sampling now reads its
+ blocks the same way.
+- `sigilbuzz-render`: AC refinement scans (`Ah > 0`) decode, as T.81 G.1.2.3 and libjpeg
+ read them (#291, #293). libjpeg's default progression, which Pillow writes, uses them,
+ so no such file decoded; all 432 Pillow-written test files now decode to the same
+ pixels as their baseline twins. A DC refinement scan no longer needs a DC Huffman
+ table.
+- `sigilbuzz-paint` 0.4.0: `PaintVar*`, `VarColorStop` and clip box deltas resolve at
+ the coords rounded to F2DOT14, as HarfBuzz does and as shaping and outlines already
+ did, so varied paints no longer drift up to half a unit from HarfBuzz's and from the
+ outlines they fill (#291, #293).
+- `sigilbuzz-subset`: hinted CFF2 fonts subset (#290, #294). Blended stem hints were not
+ counted, so hint masks were misread: Source Serif 4 VF failed "Hello" and 553 of its
+ 1,463 single-glyph subsets with "CFF unknown charstring operator". All of them now
+ subset and draw as the source in sigilbuzz, fontTools and HarfBuzz.
+- `sigilbuzz-subset`: a hint mask in a subroutine counts the stems its callers declared
+ (#290, #294). Hinted CFF1 fonts with masks in subroutines failed or kept the wrong
+ subroutines; across 779 CFF fonts, 2,965 of 21,650 test subsets that failed now
+ succeed, and 32 that succeeded with wrong outlines are now right.
+- `sigilbuzz-subset`: `instance` sets `OS/2.usWeightClass`, `usWidthClass` and
+ `xAvgCharWidth` and `post.italicAngle` from the pinned `wght`, `wdth` and `slnt` and
+ the instance's advances, as HarfBuzz's instancer does, rounding halves up (#290,
+ #294). They kept the source's values.
+
+Known limitations:
+
+- A `VARC` table in the MultiItemVariationStore layout HarfBuzz adopted after 14.5.0 is
+ not read; it counts as absent, as in HarfBuzz 14.5.0 (#299).
+- A fuzzed font whose GSUB grows a 30-character Devanagari text to 3,079 glyphs takes up
+ to 0.8 s to shape natively and about 24 s in a fuzz build (#302). It is not specific
+ to the new caches.
+- The caches cut the setup of each call, not the work per glyph: long runs still take 2
+ to 5 times as long per glyph as HarfBuzz (100-character runs: normalization, Hangul
+ composition, PairPos walks). A `Font` built for every run gets only the first-call
+ savings; keep one `Font` per font, size and instance to get the rest.
+- `gvar` works out each tuple's scalar over every axis for each glyph, with no
+ per-shared-tuple cache, so fonts with very many axes are slow. No real font comes
+ close, but a crafted 2,000-axis font takes 4 to 7.6 s to instance partially, against
+ 3.7 s in HarfBuzz.
+- `sigilbuzz-subset`: a partial CFF2 instance keeps the fractional values its store
+ projection gives, where HarfBuzz rounds each folded default and projected delta.
+ Source Serif 4 VF at `wght=650,opsz=keep` comes out at 2.59 MB against HarfBuzz's
+ 0.96 MB, and its default outlines differ from HarfBuzz's.
+
## 0.23.1 (2026-10-04)
The crates in this release: `sigilbuzz` 0.23.1, `sigilbuzz-render` 0.10.1, and
diff --git a/Cargo.toml b/Cargo.toml
index 67f22bd..c1873ec 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,11 +18,11 @@ repository = "https://github.com/Oneiriq/sigilbuzz"
[workspace.dependencies]
# Shared version pin. Companion crates use `sigilbuzz = { workspace = true }`
# to pick up the shaper.
-sigilbuzz = { path = ".", version = "0.23.1" }
+sigilbuzz = { path = ".", version = "0.24.0" }
[package]
name = "sigilbuzz"
-version = "0.23.1"
+version = "0.24.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
diff --git a/README.md b/README.md
index a60827f..26129eb 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ same output for the same input every time.
## Status
-The current release is 0.23.1. sigilbuzz is still pre-1.0, so a minor release can
+The current release is 0.24.0. sigilbuzz is still pre-1.0, so a minor release can
change the API. The names exported from the crate root are the ones I intend to keep
stable. [docs/STABILITY.md](docs/STABILITY.md) lists them.
@@ -61,7 +61,7 @@ Each crate has its own README with an example.
```toml
[dependencies]
-sigilbuzz = "0.23"
+sigilbuzz = "0.24"
```
```rust
diff --git a/crates/sigilbuzz-capi/Cargo.toml b/crates/sigilbuzz-capi/Cargo.toml
index 9a3e8f7..8adf8b5 100644
--- a/crates/sigilbuzz-capi/Cargo.toml
+++ b/crates/sigilbuzz-capi/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-capi"
-version = "0.4.0"
+version = "0.5.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
@@ -77,10 +77,10 @@ capi = []
sigilbuzz = { workspace = true }
# Optional, enabled by the `subset` cargo feature. Provides the
# subsetter sigilbuzz-capi's `hb_subset_*` shim wraps.
-sigilbuzz-subset = { path = "../sigilbuzz-subset", version = "0.13.0", optional = true }
+sigilbuzz-subset = { path = "../sigilbuzz-subset", version = "0.14.0", optional = true }
# Optional, enabled by the `paint` cargo feature. Provides the
# COLRv1 evaluator sigilbuzz-capi's `hb_paint_*` shim wraps.
-sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.3.0", optional = true }
+sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.4.0", optional = true }
[dev-dependencies]
# `cc` builds the C source files in `tests/c/` against the cdylib
diff --git a/crates/sigilbuzz-cli/Cargo.toml b/crates/sigilbuzz-cli/Cargo.toml
index 5807c3a..9caa2b0 100644
--- a/crates/sigilbuzz-cli/Cargo.toml
+++ b/crates/sigilbuzz-cli/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-cli"
-version = "0.2.0"
+version = "0.3.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
@@ -18,12 +18,12 @@ path = "src/main.rs"
[dependencies]
sigilbuzz = { workspace = true }
-sigilbuzz-subset = { path = "../sigilbuzz-subset", version = "0.13.0" }
-sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.3.0" }
-sigilbuzz-gpu = { path = "../sigilbuzz-gpu", version = "0.2.0" }
-sigilbuzz-svg = { path = "../sigilbuzz-svg", version = "0.3.0", features = ["color"] }
-sigilbuzz-pdf = { path = "../sigilbuzz-pdf", version = "0.3.0" }
-sigilbuzz-woff = { path = "../sigilbuzz-woff", version = "0.3.2" }
+sigilbuzz-subset = { path = "../sigilbuzz-subset", version = "0.14.0" }
+sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.4.0" }
+sigilbuzz-gpu = { path = "../sigilbuzz-gpu", version = "0.3.0" }
+sigilbuzz-svg = { path = "../sigilbuzz-svg", version = "0.4.0", features = ["color"] }
+sigilbuzz-pdf = { path = "../sigilbuzz-pdf", version = "0.4.0" }
+sigilbuzz-woff = { path = "../sigilbuzz-woff", version = "0.3.3" }
clap = { version = "4", features = ["derive"] }
[dev-dependencies]
diff --git a/crates/sigilbuzz-gpu/Cargo.toml b/crates/sigilbuzz-gpu/Cargo.toml
index b8dcbc3..6b3bc94 100644
--- a/crates/sigilbuzz-gpu/Cargo.toml
+++ b/crates/sigilbuzz-gpu/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-gpu"
-version = "0.2.0"
+version = "0.3.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
diff --git a/crates/sigilbuzz-hyphen/Cargo.toml b/crates/sigilbuzz-hyphen/Cargo.toml
index f523f59..162e5b2 100644
--- a/crates/sigilbuzz-hyphen/Cargo.toml
+++ b/crates/sigilbuzz-hyphen/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-hyphen"
-version = "0.2.0"
+version = "0.3.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
@@ -26,7 +26,7 @@ patterns-es = ["std"]
text-layout-integration = ["dep:sigilbuzz-text-layout"]
[dependencies]
-sigilbuzz-text-layout = { path = "../sigilbuzz-text-layout", version = "0.2.0", optional = true }
+sigilbuzz-text-layout = { path = "../sigilbuzz-text-layout", version = "0.3.0", optional = true }
[lints]
workspace = true
diff --git a/crates/sigilbuzz-paint/Cargo.toml b/crates/sigilbuzz-paint/Cargo.toml
index afe12cf..7d207e9 100644
--- a/crates/sigilbuzz-paint/Cargo.toml
+++ b/crates/sigilbuzz-paint/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-paint"
-version = "0.3.0"
+version = "0.4.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
diff --git a/crates/sigilbuzz-pdf/Cargo.toml b/crates/sigilbuzz-pdf/Cargo.toml
index 3e05753..43a6178 100644
--- a/crates/sigilbuzz-pdf/Cargo.toml
+++ b/crates/sigilbuzz-pdf/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-pdf"
-version = "0.3.0"
+version = "0.4.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
diff --git a/crates/sigilbuzz-render/Cargo.toml b/crates/sigilbuzz-render/Cargo.toml
index 6714d61..09b3f36 100644
--- a/crates/sigilbuzz-render/Cargo.toml
+++ b/crates/sigilbuzz-render/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-render"
-version = "0.10.1"
+version = "0.11.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
@@ -18,7 +18,7 @@ std = []
[dependencies]
sigilbuzz = { workspace = true }
-sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.3.0" }
+sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.4.0" }
# `miniz_oxide` provides the zlib INFLATE primitive used by the
# embedded PNG decoder for CBDT / sbix bitmap tables. Same dep
# already pulled in by `sigilbuzz-woff` for WOFF1 zlib (see
diff --git a/crates/sigilbuzz-subset/Cargo.toml b/crates/sigilbuzz-subset/Cargo.toml
index 53995c5..2793e07 100644
--- a/crates/sigilbuzz-subset/Cargo.toml
+++ b/crates/sigilbuzz-subset/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-subset"
-version = "0.13.1"
+version = "0.14.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
diff --git a/crates/sigilbuzz-svg/Cargo.toml b/crates/sigilbuzz-svg/Cargo.toml
index de5739b..836c6e3 100644
--- a/crates/sigilbuzz-svg/Cargo.toml
+++ b/crates/sigilbuzz-svg/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-svg"
-version = "0.3.0"
+version = "0.4.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
@@ -21,7 +21,7 @@ color = ["dep:sigilbuzz-paint"]
[dependencies]
sigilbuzz = { workspace = true }
-sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.3.0", optional = true }
+sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.4.0", optional = true }
[dev-dependencies]
diff --git a/crates/sigilbuzz-text-layout/Cargo.toml b/crates/sigilbuzz-text-layout/Cargo.toml
index 872ae5d..3ca92bf 100644
--- a/crates/sigilbuzz-text-layout/Cargo.toml
+++ b/crates/sigilbuzz-text-layout/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-text-layout"
-version = "0.2.0"
+version = "0.3.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
diff --git a/crates/sigilbuzz-woff/Cargo.toml b/crates/sigilbuzz-woff/Cargo.toml
index 5777db9..0e4ae45 100644
--- a/crates/sigilbuzz-woff/Cargo.toml
+++ b/crates/sigilbuzz-woff/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "sigilbuzz-woff"
-version = "0.3.2"
+version = "0.3.3"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index 1d93a10..572d0b9 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -1,10 +1,273 @@
# Performance
-Criterion numbers from the 0.6.0 release, measured on the maintainer's macOS arm64
-workstation. If you change a hot path, re-run the benchmarks and update this file in
-the same change, so the history stays in one place.
+Numbers for 0.24.0. The per-call shaping tables come from the measurements in the
+0.24.0 pull requests (#295 and #301), which compare sigilbuzz with 0.23.0 and with
+HarfBuzz 14.5.0. The Criterion numbers further down were rerun for this release. If you
+change a hot path, measure again and update this file in the same change, so the history
+stays in one place. Numbers from earlier releases are in this file's git history.
-## Running the benchmarks
+## How 0.24.0 shapes faster
+
+A `Font` keeps what HarfBuzz keeps per face and font, from its second shaping call on:
+
+- a glyph digest per lookup and subtable, so a lookup that cannot apply to any glyph of
+ the run is skipped before it is parsed, and subtables are parsed only when a glyph
+ their digest admits reaches them;
+- the language system each script, language and FeatureVariations record resolves to;
+- the merged lookups of each shaping stage;
+- per-glyph vertical origins and varied advances (`HVAR`, `VVAR` or phantom points).
+
+Clones made after a font's first call share these caches. A font's first call builds
+none of them, so code that builds a `Font` for every run gets only part of the gain.
+Keep one `Font` per font, size and instance. The Indic, Khmer, Myanmar and USE syllable
+grammars are compiled once per process. Output never depends on the caches.
+
+## Per-call shaping
+
+### Method
+
+- One Windows 11 laptop (Intel i9-11900H), shared with other jobs at about 25%
+ background load, so differences under about 4% are noise.
+- Release builds with `lto = "thin"` and `codegen-units = 1`, one sigilbuzz version per
+ binary, as a user links it.
+- Each row shapes 20 texts of 1, 8 or 100 characters (Hangul syllables, Latin letters
+ and spaces, Arabic letters, Devanagari words) with no features, either after
+ `guess_segment_properties` (LTR: horizontal, right to left for Arabic) or top to
+ bottom (TTB).
+- A run reports the median thread-cycle time of 11 batches of about 10 ms. The versions
+ of a row run in alternation, and a cell is the median over 5 runs (3 for first
+ calls). Times are microseconds per call.
+- The 0.23.0 and 0.24.0 columns, and the hostile-table numbers below, were measured
+ for #301 on its branch. Its last two commits came after: one changes only first
+ calls of lookups with more than 1,024 subtables, which were measured again and were
+ within noise, and one caps how much room a lookup's parsed subtables reserve, which
+ was not timed again.
+- HarfBuzz is 14.5.0 through uharfbuzz 0.56.2, measured for #295 on the same laptop:
+ the median of 15 wall-clock batches from Python, best of 2. Each HarfBuzz number
+ includes about 0.5 to 1 us of Python per call, so HarfBuzz's own time is that much
+ lower.
+
+### Repeated calls on one `Font`
+
+This is the case the caches are for, and what an application that keeps its fonts
+sees.
+
+| Font | Axes | Dir | Chars | 0.23.0 | 0.24.0 | HarfBuzz 14.5.0 |
+|---|---|---|---|---|---|---|
+| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 1 | 56.51 | 3.38 | 1.13 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 8 | 70.60 | 7.73 | 1.82 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 100 | 228.42 | 42.49 | 20.51 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 1 | 59.31 | 3.42 | 1.85 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 8 | 66.63 | 6.38 | 3.08 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 100 | 222.00 | 36.70 | 25.30 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 1 | 58.89 | 3.47 | 1.90 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 8 | 71.06 | 8.86 | 2.29 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 100 | 246.20 | 47.66 | 19.61 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 1 | 57.70 | 3.61 | 1.63 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 8 | 71.24 | 7.47 | 2.61 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 100 | 266.18 | 54.90 | 30.32 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 1 | 57.47 | 3.21 | 1.27 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 8 | 92.63 | 8.12 | 1.85 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 100 | 220.00 | 44.09 | 16.01 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 1 | 58.17 | 3.30 | 1.93 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 8 | 66.09 | 6.32 | 2.44 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 100 | 231.34 | 37.37 | 27.33 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 1 | 57.77 | 3.46 | 1.24 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 8 | 66.77 | 7.77 | 1.88 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 100 | 253.46 | 49.81 | 22.09 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 1 | 56.65 | 3.67 | 2.08 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 8 | 85.79 | 7.68 | 3.62 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 100 | 315.74 | 50.65 | 62.87 |
+| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 1 | 13.19 | 2.97 | 1.63 |
+| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 8 | 16.96 | 4.59 | 1.75 |
+| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 100 | 90.39 | 22.29 | 15.19 |
+| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 1 | 7.07 | 2.99 | 1.45 |
+| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 8 | 10.12 | 4.39 | 7.87 |
+| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 100 | 66.91 | 20.13 | 227.45 |
+| Source Code Pro (CFF, no VORG) | default | LTR | 1 | 1.02 | 1.11 | 1.08 |
+| Source Code Pro (CFF, no VORG) | default | LTR | 8 | 1.61 | 1.71 | 2.23 |
+| Source Code Pro (CFF, no VORG) | default | LTR | 100 | 7.05 | 8.06 | 5.15 |
+| Source Code Pro (CFF, no VORG) | default | TTB | 1 | 1.12 | 1.76 | 1.78 |
+| Source Code Pro (CFF, no VORG) | default | TTB | 8 | 1.87 | 1.88 | 1.41 |
+| Source Code Pro (CFF, no VORG) | default | TTB | 100 | 9.54 | 8.57 | 5.22 |
+| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 1 | 9.76 | 3.64 | 1.67 |
+| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 8 | 14.24 | 6.90 | 2.41 |
+| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 100 | 72.64 | 43.68 | 10.85 |
+| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 1 | 7.14 | 2.81 | 1.47 |
+| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 8 | 10.58 | 4.68 | 1.65 |
+| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 100 | 49.00 | 22.83 | 4.78 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 1 | 9.84 | 3.95 | 1.32 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 8 | 18.96 | 8.15 | 2.21 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 100 | 118.53 | 62.23 | 13.48 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 1 | 7.64 | 3.11 | 1.51 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 8 | 13.03 | 4.71 | 1.77 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 100 | 77.40 | 23.66 | 6.33 |
+| Segoe UI | default | LTR | 1 | 7.08 | 2.29 | 1.47 |
+| Segoe UI | default | LTR | 8 | 10.12 | 3.78 | 2.32 |
+| Segoe UI | default | LTR | 100 | 53.78 | 21.86 | 5.63 |
+| Segoe UI | default | TTB | 1 | 6.33 | 2.15 | 2.12 |
+| Segoe UI | default | TTB | 8 | 9.75 | 3.24 | 1.86 |
+| Segoe UI | default | TTB | 100 | 50.46 | 16.35 | 4.80 |
+| Roboto | default | LTR | 1 | 5.41 | 2.68 | 1.29 |
+| Roboto | default | LTR | 8 | 8.17 | 5.72 | 2.16 |
+| Roboto | default | LTR | 100 | 42.36 | 36.23 | 9.67 |
+| Roboto | default | TTB | 1 | 4.19 | 2.14 | 1.63 |
+| Roboto | default | TTB | 8 | 6.08 | 4.01 | 1.58 |
+| Roboto | default | TTB | 100 | 28.50 | 20.18 | 3.71 |
+| Amiri (Arabic) | default | LTR | 1 | 58.51 | 9.63 | 1.63 |
+| Amiri (Arabic) | default | LTR | 8 | 90.63 | 30.44 | 8.14 |
+| Amiri (Arabic) | default | LTR | 100 | 413.22 | 208.64 | 78.07 |
+| Amiri (Arabic) | default | TTB | 1 | 46.15 | 4.17 | 1.60 |
+| Amiri (Arabic) | default | TTB | 8 | 56.94 | 8.53 | 4.09 |
+| Amiri (Arabic) | default | TTB | 100 | 216.28 | 49.65 | 18.13 |
+| Noto Sans Devanagari | default | LTR | 1 | 370.87 | 10.12 | 1.98 |
+| Noto Sans Devanagari | default | LTR | 8 | 438.20 | 29.12 | 5.38 |
+| Noto Sans Devanagari | default | LTR | 100 | 806.56 | 204.08 | 52.49 |
+| Noto Sans Devanagari | default | TTB | 1 | 360.25 | 9.98 | 2.59 |
+| Noto Sans Devanagari | default | TTB | 8 | 422.78 | 37.47 | 6.88 |
+| Noto Sans Devanagari | default | TTB | 100 | 799.29 | 206.75 | 55.33 |
+
+Long runs still take 2 to 5 times as long per glyph as in HarfBuzz: normalization,
+Hangul composition and PairPos walks did not change in 0.24.0.
+
+### A fresh `Face` and `Font` for every call
+
+The first-call path, for code that builds a `Font` per run. A font's first
+top-to-bottom call is slower than in 0.23.0 for fonts without `VORG` (Hahmlet, Source
+Serif 4 VF, Source Code Pro), because their vertical origins come from glyph extents
+since 0.23.1, and a first call works those out for every new glyph.
+
+Full table
+
+| Font | Axes | Dir | Chars | 0.23.0 | 0.24.0 |
+|---|---|---|---|---|---|
+| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 1 | 56.45 | 12.74 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 8 | 63.65 | 25.76 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 100 | 218.88 | 163.43 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 1 | 56.43 | 12.54 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 8 | 65.47 | 23.08 |
+| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 100 | 216.12 | 170.51 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 1 | 59.94 | 13.55 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 8 | 67.88 | 26.94 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 100 | 244.63 | 175.47 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 1 | 53.85 | 13.17 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 8 | 68.31 | 25.68 |
+| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 100 | 240.42 | 190.12 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 1 | 56.16 | 13.46 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 8 | 68.36 | 24.07 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 100 | 211.36 | 159.16 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 1 | 54.41 | 12.87 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 8 | 65.31 | 23.67 |
+| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 100 | 212.30 | 172.75 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 1 | 54.40 | 13.41 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 8 | 68.45 | 26.02 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 100 | 244.49 | 184.43 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 1 | 54.41 | 13.89 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 8 | 81.58 | 30.00 |
+| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 100 | 347.54 | 254.19 |
+| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 1 | 13.33 | 9.58 |
+| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 8 | 17.70 | 11.82 |
+| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 100 | 91.97 | 74.23 |
+| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 1 | 7.09 | 13.33 |
+| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 8 | 10.53 | 41.80 |
+| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 100 | 69.16 | 510.91 |
+| Source Code Pro (CFF, no VORG) | default | LTR | 1 | 1.12 | 1.20 |
+| Source Code Pro (CFF, no VORG) | default | LTR | 8 | 1.70 | 1.83 |
+| Source Code Pro (CFF, no VORG) | default | LTR | 100 | 7.21 | 8.01 |
+| Source Code Pro (CFF, no VORG) | default | TTB | 1 | 1.23 | 1.97 |
+| Source Code Pro (CFF, no VORG) | default | TTB | 8 | 1.94 | 4.35 |
+| Source Code Pro (CFF, no VORG) | default | TTB | 100 | 9.67 | 24.41 |
+| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 1 | 9.07 | 6.47 |
+| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 8 | 13.92 | 11.60 |
+| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 100 | 69.69 | 54.30 |
+| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 1 | 7.69 | 9.61 |
+| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 8 | 11.31 | 27.84 |
+| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 100 | 50.00 | 160.56 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 1 | 9.56 | 7.03 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 8 | 19.12 | 16.12 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 100 | 124.17 | 104.79 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 1 | 8.78 | 10.55 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 8 | 14.39 | 34.31 |
+| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 100 | 81.98 | 189.34 |
+| Segoe UI | default | LTR | 1 | 7.08 | 6.35 |
+| Segoe UI | default | LTR | 8 | 10.76 | 9.35 |
+| Segoe UI | default | LTR | 100 | 58.14 | 44.59 |
+| Segoe UI | default | TTB | 1 | 6.58 | 5.55 |
+| Segoe UI | default | TTB | 8 | 9.95 | 8.15 |
+| Segoe UI | default | TTB | 100 | 52.04 | 36.11 |
+| Roboto | default | LTR | 1 | 5.84 | 5.62 |
+| Roboto | default | LTR | 8 | 8.24 | 8.89 |
+| Roboto | default | LTR | 100 | 42.39 | 47.02 |
+| Roboto | default | TTB | 1 | 4.58 | 3.90 |
+| Roboto | default | TTB | 8 | 7.23 | 6.03 |
+| Roboto | default | TTB | 100 | 29.56 | 31.59 |
+| Amiri (Arabic) | default | LTR | 1 | 63.74 | 17.53 |
+| Amiri (Arabic) | default | LTR | 8 | 88.06 | 50.56 |
+| Amiri (Arabic) | default | LTR | 100 | 455.94 | 388.00 |
+| Amiri (Arabic) | default | TTB | 1 | 46.51 | 11.32 |
+| Amiri (Arabic) | default | TTB | 8 | 58.75 | 22.69 |
+| Amiri (Arabic) | default | TTB | 100 | 213.42 | 164.33 |
+| Noto Sans Devanagari | default | LTR | 1 | 370.78 | 45.64 |
+| Noto Sans Devanagari | default | LTR | 8 | 464.15 | 70.46 |
+| Noto Sans Devanagari | default | LTR | 100 | 847.40 | 499.24 |
+| Noto Sans Devanagari | default | TTB | 1 | 380.26 | 40.97 |
+| Noto Sans Devanagari | default | TTB | 8 | 421.72 | 68.84 |
+| Noto Sans Devanagari | default | TTB | 100 | 776.64 | 498.88 |
+
+
+
+### Hostile layout tables
+
+Open Sans with its GSUB or GPOS replaced by a crafted table, measured for #301. Time per
+call on one `Font` (a first call in parentheses): the median of 9 alternating runs for
+the rows in milliseconds, and the range of four single calls for the rows in seconds.
+Output is the same in both versions.
+
+| Table | Text | 0.23.1 | 0.24.0 |
+|---|---|---|---|
+| GSUB, 32,000 offsets to one ChainContextFormat2 (64 KB) | aaaa | 5.19 ms (4.93) | 5.49 ms (5.47) |
+| GSUB, 8,000 of them (16 KB) | aaaa | 1.31 ms (1.36) | 1.37 ms (1.44) |
+| GSUB, 32,000 offsets to one ChainContextFormat3 (64 KB) | a | 6.80 ms (6.56) | 6.10 ms (6.49) |
+| GSUB, 32,000 offsets to one ChainContextFormat1 (64 KB) | aaaa | 3.99 ms (4.02) | 4.32 ms (4.37) |
+| GPOS, 32,000 offsets to one ChainContextPos format 2 (64 KB) | aaaa | 4.85 ms (5.06) | 5.30 ms (5.61) |
+| GSUB, 500 indices of one lookup of 32,000 format 3 subtables (66 KB) | a | 5.6 to 7.0 s | 4.5 to 5.9 s |
+| GPOS, the same with ChainContextPos (66 KB) | a | 6.1 to 6.7 s | 4.1 to 4.7 s |
+| GSUB, 8,000 overlapping lookups of 8,000 subtables (96 KB) | a | 1.6 to 2.0 s | 2.2 to 2.5 s |
+
+Heap over three calls on one `Font`, measured with a counting allocator: the peak
+during the calls, and what the `Font` keeps after them.
+
+| Table | 0.23.1 peak / kept | 0.24.0 peak / kept |
+|---|---|---|
+| GSUB, 32,000 x ChainContextFormat2 | 5.4 MB / 0 | 5.5 MB / 2 KB |
+| GPOS, 32,000 x format 2 | 5.6 MB / 0 | 5.8 MB / 3 KB |
+| GSUB, 500 indices x 32,000 format 3 | 7.3 MB / 0 | 7.5 MB / 15 KB |
+| GPOS, 500 indices x 32,000 format 3 | 7.6 MB / 0 | 7.7 MB / 15 KB |
+| GSUB, 8,000 overlapping lookups | 1.4 MB / 0 | 7.8 MB / 6.3 MB |
+| GSUB, 16 lookups x 2,000 distinct digests | 0.3 MB / 0 | 0.7 MB / 0.7 MB |
+
+What a `Font` keeps per GSUB or GPOS table is one slot pointer per lookup, a 40-byte
+header per lookup applied, and 24 bytes per kept digest, for at most twice the table's
+length plus 64 Ki digests. On top of that come at most 16 resolved language systems and
+64 stage plans per table, and three per-glyph caches of at most 4,096 four-byte entries
+each. The overlapping-lookups table is the one case only that budget bounds: 6.3 MB
+kept for a 96 KB table.
+
+### Outlines
+
+`Face::glyph_outlines` against `Face::glyph_outline_at_coords`, per glyph over 100
+consecutive glyphs, measured for #295:
+
+| Font | `glyph_outline_at_coords` | `GlyphOutlines` |
+|---|---|---|
+| Source Serif 4 VF (CFF2), default | 5.12 us | 3.74 us |
+| Source Serif 4 VF (CFF2), wght 700 | 5.29 us | 3.68 us |
+| Noto Sans KR VF otf (CFF2) | 2.98 us | 2.09 us |
+| Source Code Pro (CFF) | 0.75 us | 0.44 us |
+
+## Criterion benchmarks
+
+### Running them
```sh
# Every benchmark in the workspace (Latin, Arabic, Devanagari, Khmer, Hebrew, plus
@@ -20,83 +283,59 @@ cargo bench -p sigilbuzz-paint --bench evaluate
cargo bench --bench shape_latin -- --quick
```
-Each shaping benchmark runs the same 200-codepoint text through sigilbuzz and
-rustybuzz back to back. Absolute times depend on the machine. The ratio against
-rustybuzz is the number to watch, because it stays stable across machines.
+Each shaping benchmark shapes the same 200-codepoint text through sigilbuzz and
+rustybuzz back to back. sigilbuzz shapes on one `Font`, so its caches are warm after
+the first iteration. Absolute times depend on the machine. The ratio against rustybuzz
+is the number to watch, because it moves less across machines.
+
+### Results for 0.24.0
-## Shaping: sigilbuzz vs rustybuzz
+Measured on 2026-10-04 at the 0.24.0 release commit with `cargo bench --workspace
+--bench shape_latin --bench shape_arabic --bench shape_devanagari --bench shape_khmer
+--bench shape_hebrew --bench encode --bench evaluate`, Criterion's default settings
+(3 s warm-up, 100 samples), on the laptop above. Each time is the middle of Criterion's
+estimate. Other jobs ran on the laptop at the same time (12% to 25% CPU load at the
+start and end of the run). A first run under full load took up to twice as long, and
+its ratios differed by up to a factor of two (Khmer 5.5x instead of 2.45x), so read the
+ratios as approximate and rerun on a quiet machine before comparing releases. The
+0.6.0 numbers in this file's history came from a macOS arm64 workstation, so their
+absolute times do not compare with these.
| Benchmark | sigilbuzz | rustybuzz | Ratio | Notes |
|---|---|---|---|---|
-| shape_latin | 11.39 µs | 15.03 µs | 0.76x | Open Sans, ASCII pangrams with liga and kern. |
-| shape_arabic | 290 µs | 108 µs | 2.70x | Amiri, heavy rlig use with IgnoreMarks. |
-| shape_devanagari | 419 µs | 85.8 µs | 4.89x | Noto Sans Devanagari, reph and conjuncts. |
-| shape_khmer | 91.6 µs | 49.0 µs | 1.87x | Noto Sans Khmer, USE state machine. |
-| shape_hebrew | 10.99 µs | 17.28 µs | 0.64x | Noto Sans Hebrew, GPOS mark-to-base and mark-to-mark. |
+| shape_latin | 50.9 µs | 37.5 µs | 1.36x | Open Sans, ASCII pangrams with liga and kern. |
+| shape_arabic | 455 µs | 263 µs | 1.73x | Amiri, heavy rlig use with IgnoreMarks. |
+| shape_devanagari | 772 µs | 250 µs | 3.09x | Noto Sans Devanagari, reph and conjuncts. |
+| shape_khmer | 342 µs | 140 µs | 2.45x | Noto Sans Khmer, Khmer shaper. |
+| shape_hebrew | 62.1 µs | 41.4 µs | 1.50x | Noto Sans Hebrew, GPOS mark-to-base and mark-to-mark. |
A ratio below 1 means sigilbuzz is faster.
-### What changed in 0.6.0 (#74, #75, #76)
+GPU encoder (`sigilbuzz-gpu`). rustybuzz has no Slug-style outline encoder, so these
+numbers are only compared with earlier sigilbuzz-gpu runs.
-In 0.5.0, Devanagari took 173x as long as rustybuzz, Arabic 32x, and Khmer 17x. Every
-complex-script benchmark was an outlier for algorithmic reasons. Three fixes in
-`src/shape.rs` and `src/tables/gsub/*.rs` brought each of them under 5x and left Latin
-and Hebrew faster than rustybuzz:
-
-1. Build the glyph ID snapshot once. The context, chained-context, and reverse-chain
- matchers used to rebuild a `Vec` of every glyph ID at every cursor step. Now
- `apply_gsub_lookup` builds it once (`GlyphIds`) and updates it as it goes. Single and
- Alternate substitutions touch one slot. Ligature and Multiple resync. Devanagari
- -17%.
-
-2. Parse each subtable once per lookup. Parsing a format 3 context subtable allocates
- four `Vec`s, and the cursor walk used to pay that at every position. The parsed form
- (`ParsedGsubSubtable`) is now built once when `apply_gsub_lookup` starts and reused
- for the whole walk. Devanagari -94%, Arabic -86%, Khmer -75%.
-
-3. Skip lookups that can't apply. Each lookup first checks whether any glyph in the
- run is in any subtable's coverage. During the walk, only positions whose glyph is in
- that set get full subtable dispatch. The rest skip after one `cov.contains` check
- per subtable. This mirrors HarfBuzz's skip-iterator check. Devanagari -23%, Arabic
- -37%, Khmer -47%.
-
-The chained-context matchers also check `input[0]` before walking the backtrack
-sequence, as HarfBuzz does. The per-syllable `compute_half_mask` dry run now caches
-`feature_would_substitute` results, so repeated `(halant, c2)` pairs don't walk GSUB
-again.
-
-Latin and Hebrew got faster as a side effect. The cursor walk savings also apply to
-the default `liga`, `calt`, and `clig` features and to `mark` and `mkmk` in GPOS.
-
-## GPU encoder (`sigilbuzz-gpu`)
-
-rustybuzz has no Slug-style outline encoder, so these numbers are only compared with
-earlier sigilbuzz-gpu runs.
-
-| Glyph | Mean | Notes |
+| Glyph | Time | Notes |
|---|---|---|
-| opensans_A | 852 ns | Simple glyph, mostly straight lines. |
-| opensans_g | 1.78 µs | Descender and a closed two-story bowl. |
-| opensans_O | 979 ns | An oval, all four curved quadrants. |
-| amiri_alef | 896 ns | Long vertical stroke with a thin tail. |
-
-## COLRv1 evaluator (`sigilbuzz-paint`)
+| opensans_A | 3.57 µs | Simple glyph, mostly straight lines. |
+| opensans_g | 6.09 µs | Descender and a closed two-story bowl. |
+| opensans_O | 3.39 µs | An oval, all four curved quadrants. |
+| amiri_alef | 3.09 µs | Long vertical stroke with a thin tail. |
-rustybuzz does not evaluate COLRv1, so these numbers are only compared with earlier
-sigilbuzz-paint runs.
+COLRv1 evaluator (`sigilbuzz-paint`). rustybuzz does not evaluate COLRv1, so these
+numbers are only compared with earlier sigilbuzz-paint runs.
-| Fixture | Mean |
+| Fixture | Time |
|---|---|
-| solid | 58 ns |
-| linear_gradient | 92 ns |
-| translate_scale | 92 ns |
-| composite | 86 ns |
-| radial_gradient | 86 ns |
-| sweep_gradient | 97 ns |
-| var_solid_at_coords | 69 ns |
+| solid | 488 ns |
+| linear_gradient | 696 ns |
+| translate_scale | 548 ns |
+| composite | 692 ns |
+| radial_gradient | 647 ns |
+| sweep_gradient | 656 ns |
+| var_solid_at_coords | 410 ns |
-## Reading the Criterion output
+### Reading the Criterion output
Each shaping benchmark defines two entries side by side, `shape_