From 85fc8981253344e7cc2024c227aa038dd07e6317 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 17:52:49 -0800 Subject: [PATCH 1/3] chore(release): prepare 0.24.0 Bumps sigilbuzz to 0.24.0 and every companion crate that takes sigilbuzz types in its API to its next minor version, per docs/RELEASING.md: capi 0.5.0, cli 0.3.0, gpu 0.3.0, hyphen 0.3.0, paint 0.4.0, pdf 0.4.0, render 0.11.0, subset 0.14.0, svg 0.4.0 and text-layout 0.3.0. sigilbuzz-woff exposes no sigilbuzz types and takes a patch bump to 0.3.3. Every internal dependency requirement moves with them. Adds the 0.24.0 changelog section for #292 to #301, updates the README's current release and quick start, the STABILITY.md version guidance and the instance_user entry, and the RELEASING.md example version. --- CHANGELOG.md | 304 ++++++++++++++++++++++++ Cargo.toml | 4 +- README.md | 4 +- crates/sigilbuzz-capi/Cargo.toml | 6 +- crates/sigilbuzz-cli/Cargo.toml | 14 +- crates/sigilbuzz-gpu/Cargo.toml | 2 +- crates/sigilbuzz-hyphen/Cargo.toml | 4 +- crates/sigilbuzz-paint/Cargo.toml | 2 +- crates/sigilbuzz-pdf/Cargo.toml | 2 +- crates/sigilbuzz-render/Cargo.toml | 4 +- crates/sigilbuzz-subset/Cargo.toml | 2 +- crates/sigilbuzz-svg/Cargo.toml | 4 +- crates/sigilbuzz-text-layout/Cargo.toml | 2 +- crates/sigilbuzz-woff/Cargo.toml | 2 +- docs/RELEASING.md | 2 +- docs/STABILITY.md | 8 +- 16 files changed, 337 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e819a80..cfe1686 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 (481 before). +- `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 6.7 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/RELEASING.md b/docs/RELEASING.md index 7095830..f4bea0a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -87,7 +87,7 @@ Crates in the same group don't depend on each other and can go in any order. ## Path plus version dependencies Workspace crates depend on each other with both a path and a version, for example -`sigilbuzz = { path = ".", version = "0.22.0" }`. Cargo uses the path when building +`sigilbuzz = { path = ".", version = "0.24.0" }`. Cargo uses the path when building locally and the version when packaging for crates.io, so there is nothing to strip before publishing. When you bump a crate, update the version in every place that depends on it. The dry run fails if they disagree. diff --git a/docs/STABILITY.md b/docs/STABILITY.md index d881e47..63a4a70 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -8,8 +8,8 @@ technical reasons, and the rules I follow when adding or changing public items. Every crate in the workspace is below 1.0, so a minor release may break the API. The crates are released together. Each release bumps the crates that changed, and one git tag covers the whole set. Companion crates keep their own version numbers, so pin -versions that came out of the same release. For 0.23.1 that means `sigilbuzz = "0.23"` -with, for example, `sigilbuzz-render = "0.10"` and `sigilbuzz-paint = "0.3"`. +versions that came out of the same release. For 0.24.0 that means `sigilbuzz = "0.24"` +with, for example, `sigilbuzz-render = "0.11"` and `sigilbuzz-paint = "0.4"`. [RELEASING.md](RELEASING.md) has the release checklist. The target is 1.0 in 2026, with a stable shaping API and a documented path for @@ -149,6 +149,10 @@ signatures only change in a major version. - `subset`, `SubsetInput`, `SubsetOutput` - `instance`, `AxisPin`, `F2Dot14`, `InstanceInput`, `InstancedOutput` +- Added in 0.14.0: `instance_user`, `UserInstanceInput` and `AxisLimit`, which instance + a font at axis values in user units, given by axis tag. Both types are + `#[non_exhaustive]`, so build the input with `UserInstanceInput::new()` and its + `with_` methods - `SubsetWarning`: a malformed piece of the source font that a subset or instance left out instead of failing, returned in `SubsetOutput::warnings` and `InstancedOutput::warnings` From 47c0d0869d1dbc5b58ec8b816d2fabe5d65e7652 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 18:01:48 -0800 Subject: [PATCH 2/3] docs: refresh the roadmap and performance numbers for 0.24.0 ROADMAP.md: "Next" now lists what is next (consumer adoption of 0.24.0, per-glyph cost of long runs, #302, feature semantics, syllabic shapers on a glyph buffer, the variation core, #299), "Known gaps" takes the deferred items from the 0.24.0 pull requests, and the gaps and known bugs that 0.24.0 or earlier releases closed are gone. "How work gets picked" says how parity is measured now that no consumer runs a HarfBuzz backend. PERFORMANCE.md: the 0.6.0 numbers give way to the 0.24.0 measurements from #295 and #301 (method, repeated calls on one Font with HarfBuzz alongside, first calls, hostile tables, GlyphOutlines) and a Criterion rerun, with how each was measured. --- docs/PERFORMANCE.md | 375 ++++++++++++++++++++++++++++++++++++-------- docs/ROADMAP.md | 155 +++++++++++++----- 2 files changed, 425 insertions(+), 105 deletions(-) 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_